agent-orchestrator/ui-electrobun/src/bun/index.ts
Hibryda 66dce7ebae fix(electrobun): 7 bug fixes + 3 partial features completed
Bugs fixed:
- Agent retry: clear dead session on failed start (was non-recoverable)
- seqId persist: save seq_id column in agent_messages, migrate existing DBs
- Window frame save: wire resize event listener to saveWindowFrame()
- Diagnostics counters: real RPC call counter via withRpcCounting() proxy

Partial features completed:
- Search auto-seed: rebuildIndex() on startup + indexMessage() on save
- Notifications: removed 3 hardcoded demo entries, start empty
- Agent cost streaming: emitCostIfChanged() on every message batch

New: src/bun/rpc-stats.ts (RPC call + dropped event counters)
2026-03-25 20:26:49 +01:00

296 lines
13 KiB
TypeScript

/**
* 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 { BrowserWindow, BrowserView, Updater, Electrobun } 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 { createBttaskDb } from "./bttask-db.ts";
import { SidecarManager } from "./sidecar-manager.ts";
import type { PtyRPCSchema } from "../shared/pty-rpc-schema.ts";
import { SearchDb } from "./search-db.ts";
import { RelayClient } from "./relay-client.ts";
import { initTelemetry, telemetry } from "./telemetry.ts";
// 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";
import { createGitHandlers } from "./handlers/git-handlers.ts";
import { createProviderHandlers } from "./handlers/provider-handlers.ts";
import { createMiscHandlers } from "./handlers/misc-handlers.ts";
import { incrementRpcCallCount, incrementDroppedEvents } from "./rpc-stats.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();
const bttaskDb = createBttaskDb(btmsgDb.getHandle());
initTelemetry();
async function connectToDaemon(retries = 5, delayMs = 500): Promise<boolean> {
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;
}
// ── Build handler maps ───────────────────────────────────────────────────
// Placeholder rpc for agent handler — will be set after rpc creation
const rpcRef: { send: Record<string, (...args: unknown[]) => void> } = { send: {} };
const ptyHandlers = createPtyHandlers(ptyClient);
const filesHandlers = createFilesHandlers();
const settingsHandlers = createSettingsHandlers(settingsDb);
const agentHandlers = createAgentHandlers(sidecarManager, sessionDb, rpcRef, searchDb);
const btmsgHandlers = createBtmsgHandlers(btmsgDb, rpcRef);
const bttaskHandlers = createBttaskHandlers(bttaskDb, rpcRef);
const searchHandlers = createSearchHandlers(searchDb);
const pluginHandlers = createPluginHandlers();
const remoteHandlers = createRemoteHandlers(relayClient, settingsDb);
const gitHandlers = createGitHandlers();
const providerHandlers = createProviderHandlers();
const miscHandlers = createMiscHandlers({
settingsDb, ptyClient, relayClient, sidecarManager, telemetry, appVersion: APP_VERSION,
});
// Window ref — handlers use closure; set after mainWindow creation
let mainWindow: BrowserWindow;
// ── RPC counter wrapper ─────────────────────────────────────────────────────
/** Wrap each handler to increment the RPC call counter on every invocation. */
function withRpcCounting<T extends Record<string, (...args: unknown[]) => unknown>>(handlers: T): T {
const wrapped: Record<string, (...args: unknown[]) => unknown> = {};
for (const [key, fn] of Object.entries(handlers)) {
wrapped[key] = (...args: unknown[]) => {
incrementRpcCallCount();
return fn(...args);
};
}
return wrapped as T;
}
// ── RPC definition ─────────────────────────────────────────────────────────
const allHandlers = withRpcCounting({
...ptyHandlers,
...filesHandlers,
...settingsHandlers,
...agentHandlers,
...btmsgHandlers,
...bttaskHandlers,
...searchHandlers,
...pluginHandlers,
...remoteHandlers,
...gitHandlers,
...providerHandlers,
...miscHandlers,
});
const rpc = BrowserView.defineRPC<PtyRPCSchema>({
maxRequestTime: 120_000,
handlers: {
requests: {
...allHandlers,
// GTK native drag/resize — delegates to window manager (zero CPU)
"window.beginResize": ({ edge }: { edge: string }) => {
try {
// Uses native C library — stored mouse state from real GTK event
const { startNativeResize } = require("./native-resize.ts");
const ok = startNativeResize(edge);
return { ok };
} catch (err) { console.error("[window.beginResize]", err); return { ok: false }; }
},
"window.beginMove": ({ button, rootX, rootY }: { button: number; rootX: number; rootY: number }) => {
try {
const { beginMoveDrag } = require("./gtk-window.ts");
const ok = beginMoveDrag((mainWindow as any).ptr, button, rootX, rootY);
return { ok };
} catch (err) { console.error("[window.beginMove]", err); return { ok: false }; }
},
// Window controls — need mainWindow closure, stay inline
"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 }; } },
"window.setFrame": ({ x, y, width, height }: { x: number; y: number; width: number; height: number }) => {
try {
mainWindow.setPosition(x, y);
mainWindow.setSize(width, height);
return { ok: true };
} catch (err) { console.error("[window.setFrame]", err); return { ok: false }; }
},
"window.clearMinSize": () => {
try {
const { ensureResizable } = require("./gtk-window.ts");
ensureResizable((mainWindow as any).ptr);
return { ok: true };
} catch (err) { console.error("[window.clearMinSize]", err); return { ok: false }; }
},
"window.gtkSetFrame": ({ x, y, width, height }: { x: number; y: number; width: number; height: number }) => {
try {
const { gtkSetFrame } = require("./gtk-window.ts");
const ok = gtkSetFrame((mainWindow as any).ptr, x, y, width, height);
return { ok };
} catch (err) { console.error("[window.gtkSetFrame]", err); return { ok: false }; }
},
"window.x11SetFrame": ({ x, y, width, height }: { x: number; y: number; width: number; height: number }) => {
try {
const { x11SetFrame } = require("./gtk-window.ts");
const ok = x11SetFrame((mainWindow as any).ptr, x, y, width, height);
return { ok };
} catch (err) { console.error("[window.x11SetFrame]", 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<string> {
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();
// Partial 1: Seed FTS5 search index on startup
try {
searchDb.rebuildIndex();
console.log("[search] FTS5 index seeded on startup");
} catch (err) {
console.error("[search] Failed to seed FTS5 index:", err);
}
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);
mainWindow = new BrowserWindow({
title: "Agent Orchestrator",
titleBarStyle: "hidden",
url,
rpc,
frame: {
width: isNaN(savedWidth) ? 1400 : savedWidth,
height: isNaN(savedHeight) ? 900 : savedHeight,
x: isNaN(savedX) ? 100 : savedX,
y: isNaN(savedY) ? 100 : savedY,
},
});
// Native resize via C shared library — deferred 3s for GTK widget realization
setTimeout(() => {
try {
const { initNativeResize } = require("./native-resize.ts");
initNativeResize((mainWindow as any).ptr, 8);
} catch (e) { console.error("[native-resize] init failed:", e); }
}, 3000);
// Prevent GTK's false Ctrl+click detection from closing the window on initial load.
// WebKitGTK reports stale modifier state (0x14 = Ctrl+Alt) after SIGTERM of previous instance,
// which Electrobun interprets as a Cmd+click → "open in new window" → closes the main window.
// Fix: intercept new-window-open globally and suppress it.
try {
// @ts-ignore — electrobunEventEmitter is an internal export
const mod = await import("electrobun/bun");
const emitter = (mod as any).electrobunEventEmitter ?? (mod as any).default?.electrobunEventEmitter;
if (emitter?.on) {
emitter.on("new-window-open", (event: any) => {
console.log(`[new-window-open] Blocked false Ctrl+click: ${JSON.stringify(event?.detail ?? event)}`);
if (event?.preventDefault) event.preventDefault();
});
console.log("[new-window-open] Handler registered successfully");
} else {
console.warn("[new-window-open] Could not find event emitter in electrobun/bun exports");
}
} catch (err) {
console.warn("[new-window-open] Could not register handler:", err);
}
console.log("Agent Orchestrator (Electrobun) started!");