/** * Electrobun Bun process — thin router that delegates to domain handler modules. * * Fix #15: Extracted handlers into src/bun/handlers/ for SRP. * Fix #2: Path traversal guard on files.list/read/write via path-guard.ts. */ import fs from "fs"; import { BrowserWindow, BrowserView, Updater } from "electrobun/bun"; import { PtyClient } from "./pty-client.ts"; import { settingsDb } from "./settings-db.ts"; import { sessionDb } from "./session-db.ts"; import { btmsgDb } from "./btmsg-db.ts"; import { bttaskDb } from "./bttask-db.ts"; import { SidecarManager } from "./sidecar-manager.ts"; import type { PtyRPCSchema } from "../shared/pty-rpc-schema.ts"; import { Database } from "bun:sqlite"; import { randomUUID } from "crypto"; import { SearchDb } from "./search-db.ts"; import { checkForUpdates, getLastCheckTimestamp } from "./updater.ts"; import { RelayClient } from "./relay-client.ts"; import { initTelemetry, telemetry } from "./telemetry.ts"; import { homedir } from "os"; import { join } from "path"; // Handler modules import { createPtyHandlers } from "./handlers/pty-handlers.ts"; import { createFilesHandlers } from "./handlers/files-handlers.ts"; import { createSettingsHandlers } from "./handlers/settings-handlers.ts"; import { createAgentHandlers } from "./handlers/agent-handlers.ts"; import { createBtmsgHandlers, createBttaskHandlers } from "./handlers/btmsg-handlers.ts"; import { createSearchHandlers } from "./handlers/search-handlers.ts"; import { createPluginHandlers } from "./handlers/plugin-handlers.ts"; import { createRemoteHandlers } from "./handlers/remote-handlers.ts"; /** Current app version — sourced from electrobun.config.ts at build time. */ const APP_VERSION = "0.0.1"; const DEV_SERVER_PORT = 9760; const DEV_SERVER_URL = `http://localhost:${DEV_SERVER_PORT}`; // ── Services ─────────────────────────────────────────────────────────────── const ptyClient = new PtyClient(); const sidecarManager = new SidecarManager(); const searchDb = new SearchDb(); const relayClient = new RelayClient(); initTelemetry(); async function connectToDaemon(retries = 5, delayMs = 500): Promise { for (let attempt = 1; attempt <= retries; attempt++) { try { await ptyClient.connect(); console.log("[agor-ptyd] Connected to PTY daemon"); return true; } catch (err) { const msg = err instanceof Error ? err.message : String(err); if (attempt < retries) { console.warn(`[agor-ptyd] Connect attempt ${attempt}/${retries} failed: ${msg}. Retrying in ${delayMs}ms...`); await new Promise((r) => setTimeout(r, delayMs)); delayMs = Math.min(delayMs * 2, 4000); } else { console.error(`[agor-ptyd] Could not connect after ${retries} attempts: ${msg}`); } } } return false; } // ── Clone helpers ────────────────────────────────────────────────────────── const BRANCH_RE = /^[a-zA-Z0-9/_.-]+$/; async function gitWorktreeAdd(mainRepoPath: string, worktreePath: string, branchName: string): Promise<{ ok: boolean; error?: string }> { const proc = Bun.spawn( ["git", "worktree", "add", worktreePath, "-b", branchName], { cwd: mainRepoPath, stderr: "pipe", stdout: "pipe" } ); const exitCode = await proc.exited; if (exitCode !== 0) { const errText = await new Response(proc.stderr).text(); return { ok: false, error: errText.trim() || `git exited with code ${exitCode}` }; } return { ok: true }; } // ── Build handler maps ─────────────────────────────────────────────────── // Placeholder rpc for agent handler — will be set after rpc creation let rpcRef: { send: Record void> } = { send: {} }; const ptyHandlers = createPtyHandlers(ptyClient); const filesHandlers = createFilesHandlers(); const settingsHandlers = createSettingsHandlers(settingsDb); const agentHandlers = createAgentHandlers(sidecarManager, sessionDb, rpcRef); const btmsgHandlers = createBtmsgHandlers(btmsgDb, rpcRef); const bttaskHandlers = createBttaskHandlers(bttaskDb, rpcRef); const searchHandlers = createSearchHandlers(searchDb); const pluginHandlers = createPluginHandlers(); const remoteHandlers = createRemoteHandlers(relayClient, settingsDb); // ── RPC definition ───────────────────────────────────────────────────────── const rpc = BrowserView.defineRPC({ maxRequestTime: 15_000, handlers: { requests: { // PTY ...ptyHandlers, // Files (with path traversal guard) ...filesHandlers, // Settings + Groups + Themes ...settingsHandlers, // Agents + Session persistence ...agentHandlers, // btmsg + bttask ...btmsgHandlers, ...bttaskHandlers, // Search ...searchHandlers, // Plugins ...pluginHandlers, // Remote ...remoteHandlers, // ── Project clone handler ────────────────────────────────────────── "project.clone": async ({ projectId, branchName }) => { try { if (!BRANCH_RE.test(branchName)) { return { ok: false, error: "Invalid branch name. Use only letters, numbers, /, _, -, ." }; } const source = settingsDb.getProject(projectId); if (!source) return { ok: false, error: `Project not found: ${projectId}` }; const mainRepoPath = source.mainRepoPath ?? source.cwd; const allProjects = settingsDb.listProjects(); const existingClones = allProjects.filter( (p) => p.cloneOf === projectId || (source.cloneOf && p.cloneOf === source.cloneOf) ); if (existingClones.length >= 3) return { ok: false, error: "Maximum 3 clones per project reached" }; const cloneIndex = existingClones.length + 1; const wtSuffix = randomUUID().slice(0, 8); const worktreePath = `${mainRepoPath}-wt-${wtSuffix}`; const gitResult = await gitWorktreeAdd(mainRepoPath, worktreePath, branchName); if (!gitResult.ok) return { ok: false, error: gitResult.error }; const cloneId = `${projectId}-clone-${cloneIndex}-${randomUUID().slice(0, 8)}`; const cloneConfig = { id: cloneId, name: `${source.name} [${branchName}]`, cwd: worktreePath, accent: source.accent, provider: source.provider, profile: source.profile, model: source.model, groupId: source.groupId ?? "dev", mainRepoPath, cloneOf: projectId, worktreePath, worktreeBranch: branchName, cloneIndex, }; settingsDb.setProject(cloneId, cloneConfig); return { ok: true, project: { id: cloneId, config: JSON.stringify(cloneConfig) } }; } catch (err) { const error = err instanceof Error ? err.message : String(err); console.error("[project.clone]", err); return { ok: false, error }; } }, // ── Window controls ──────────────────────────────────────────────── "window.minimize": () => { try { mainWindow.minimize(); return { ok: true }; } catch (err) { console.error("[window.minimize]", err); return { ok: false }; } }, "window.maximize": () => { try { const frame = mainWindow.getFrame(); if (frame.x <= 0 && frame.y <= 0) mainWindow.unmaximize(); else mainWindow.maximize(); return { ok: true }; } catch (err) { console.error("[window.maximize]", err); return { ok: false }; } }, "window.close": () => { try { mainWindow.close(); return { ok: true }; } catch (err) { console.error("[window.close]", err); return { ok: false }; } }, "window.getFrame": () => { try { return mainWindow.getFrame(); } catch { return { x: 0, y: 0, width: 1400, height: 900 }; } }, "window.setPosition": ({ x, y }: { x: number; y: number }) => { try { mainWindow.setPosition(x, y); return { ok: true }; } catch { return { ok: false }; } }, // ── Keybindings ──────────────────────────────────────────────────── "keybindings.getAll": () => { try { return { keybindings: settingsDb.getKeybindings() }; } catch (err) { console.error("[keybindings.getAll]", err); return { keybindings: {} }; } }, "keybindings.set": ({ id, chord }) => { try { settingsDb.setKeybinding(id, chord); return { ok: true }; } catch (err) { console.error("[keybindings.set]", err); return { ok: false }; } }, "keybindings.reset": ({ id }) => { try { settingsDb.deleteKeybinding(id); return { ok: true }; } catch (err) { console.error("[keybindings.reset]", err); return { ok: false }; } }, // ── Updater ──────────────────────────────────────────────────────── "updater.check": async () => { try { const result = await checkForUpdates(APP_VERSION); return { ...result, error: undefined }; } catch (err) { const error = err instanceof Error ? err.message : String(err); console.error("[updater.check]", err); return { available: false, version: "", downloadUrl: "", releaseNotes: "", checkedAt: Date.now(), error }; } }, "updater.getVersion": () => ({ version: APP_VERSION, lastCheck: getLastCheckTimestamp() }), // ── Memora (read-only) ──────────────────────────────────────────── "memora.search": ({ query, limit }) => { try { const dbPath = join(homedir(), ".local", "share", "memora", "memories.db"); if (!fs.existsSync(dbPath)) return { memories: [] }; const db = new Database(dbPath, { readonly: true }); try { const rows = db.query("SELECT id, content, tags, metadata, created_at as createdAt, updated_at as updatedAt FROM memories WHERE content LIKE ? ORDER BY updated_at DESC LIMIT ?").all(`%${query}%`, limit ?? 20); return { memories: rows as Array<{ id: number; content: string; tags: string; metadata: string; createdAt: string; updatedAt: string }> }; } finally { db.close(); } } catch (err) { console.error("[memora.search]", err); return { memories: [] }; } }, "memora.list": ({ limit, tag }) => { try { const dbPath = join(homedir(), ".local", "share", "memora", "memories.db"); if (!fs.existsSync(dbPath)) return { memories: [] }; const db = new Database(dbPath, { readonly: true }); try { let sql = "SELECT id, content, tags, metadata, created_at as createdAt, updated_at as updatedAt FROM memories"; const params: unknown[] = []; if (tag) { sql += " WHERE tags LIKE ?"; params.push(`%${tag}%`); } sql += " ORDER BY updated_at DESC LIMIT ?"; params.push(limit ?? 20); const rows = db.query(sql).all(...params); return { memories: rows as Array<{ id: number; content: string; tags: string; metadata: string; createdAt: string; updatedAt: string }> }; } finally { db.close(); } } catch (err) { console.error("[memora.list]", err); return { memories: [] }; } }, // ── Feature 8: Diagnostics ───────────────────────────────────────── "diagnostics.stats": () => { return { ptyConnected: ptyClient.isConnected, relayConnections: relayClient.listMachines().filter(m => m.status === "connected").length, activeSidecars: sidecarManager.listSessions().filter(s => s.status === "running").length, rpcCallCount: 0, // Placeholder — Electrobun doesn't expose RPC call count droppedEvents: 0, }; }, // ── Telemetry ───────────────────────────────────────────────────── "telemetry.log": ({ level, message, attributes }) => { try { telemetry.log(level, `[frontend] ${message}`, attributes ?? {}); return { ok: true }; } catch (err) { console.error("[telemetry.log]", err); return { ok: false }; } }, }, messages: {}, }, }); // Wire rpcRef so agent handlers can forward events to webview rpcRef.send = rpc.send; // ── Forward daemon events to WebView ────────────────────────────────────── ptyClient.on("session_output", (msg) => { if (msg.type !== "session_output") return; try { rpc.send["pty.output"]({ sessionId: msg.session_id, data: msg.data }); } catch (err) { console.error("[pty.output] forward error:", err); } }); ptyClient.on("session_closed", (msg) => { if (msg.type !== "session_closed") return; try { rpc.send["pty.closed"]({ sessionId: msg.session_id, exitCode: msg.exit_code }); } catch (err) { console.error("[pty.closed] forward error:", err); } }); // ── Forward relay events to WebView ─────────────────────────────────────── relayClient.onEvent((machineId, event) => { try { rpc.send["remote.event"]({ machineId, eventType: event.type, sessionId: event.sessionId, payload: event.payload }); } catch (err) { console.error("[remote.event] forward error:", err); } }); relayClient.onStatus((machineId, status, error) => { try { rpc.send["remote.statusChange"]({ machineId, status, error }); } catch (err) { console.error("[remote.statusChange] forward error:", err); } }); // ── App window ──────────────────────────────────────────────────────────── async function getMainViewUrl(): Promise { const channel = await Updater.localInfo.channel(); if (channel === "dev") { try { await fetch(DEV_SERVER_URL, { method: "HEAD" }); console.log(`HMR enabled: Using Vite dev server at ${DEV_SERVER_URL}`); return DEV_SERVER_URL; } catch { console.log("Vite dev server not running. Run 'bun run dev:hmr' for HMR support."); } } return "views://mainview/index.html"; } connectToDaemon(); const url = await getMainViewUrl(); const savedX = Number(settingsDb.getSetting("win_x") ?? 100); const savedY = Number(settingsDb.getSetting("win_y") ?? 100); const savedWidth = Number(settingsDb.getSetting("win_width") ?? 1400); const savedHeight = Number(settingsDb.getSetting("win_height") ?? 900); const mainWindow = new BrowserWindow({ title: "Agent Orchestrator", titleBarStyle: "default", url, rpc, frame: { width: isNaN(savedWidth) ? 1400 : savedWidth, height: isNaN(savedHeight) ? 900 : savedHeight, x: isNaN(savedX) ? 100 : savedX, y: isNaN(savedY) ? 100 : savedY, }, }); console.log("Agent Orchestrator (Electrobun) started!");