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)
This commit is contained in:
Hibryda 2026-03-25 20:26:49 +01:00
parent 0dd402e282
commit 66dce7ebae
8 changed files with 139 additions and 47 deletions

View file

@ -4,11 +4,13 @@
import type { SidecarManager } from "../sidecar-manager.ts";
import type { SessionDb } from "../session-db.ts";
import type { SearchDb } from "../search-db.ts";
export function createAgentHandlers(
sidecarManager: SidecarManager,
sessionDb: SessionDb,
sendToWebview: { send: Record<string, (...args: unknown[]) => void> },
searchDb?: SearchDb,
) {
return {
"agent.start": ({ sessionId, provider, prompt, cwd, model, systemPrompt, maxTurns, permissionMode, claudeConfigDir, extraEnv, additionalDirectories, worktreeName }: Record<string, unknown>) => {
@ -21,12 +23,38 @@ export function createAgentHandlers(
);
if (result.ok) {
// Partial 3: Track last-sent cost to emit live updates only on change
let lastCostUsd = 0;
let lastInputTokens = 0;
let lastOutputTokens = 0;
/** Emit agent.cost if cost data changed since last emit. */
function emitCostIfChanged(sid: string): void {
const sessions = sidecarManager.listSessions();
const session = sessions.find((s: Record<string, unknown>) => s.sessionId === sid);
if (!session) return;
const cost = (session.costUsd as number) ?? 0;
const inTok = (session.inputTokens as number) ?? 0;
const outTok = (session.outputTokens as number) ?? 0;
if (cost === lastCostUsd && inTok === lastInputTokens && outTok === lastOutputTokens) return;
lastCostUsd = cost;
lastInputTokens = inTok;
lastOutputTokens = outTok;
try {
(sendToWebview.send as Record<string, Function>)["agent.cost"]({
sessionId: sid, costUsd: cost, inputTokens: inTok, outputTokens: outTok,
});
} catch { /* ignore */ }
}
sidecarManager.onMessage(sessionId as string, (sid: string, messages: unknown) => {
try {
(sendToWebview.send as Record<string, Function>)["agent.message"]({ sessionId: sid, messages });
} catch (err) {
console.error("[agent.message] forward error:", err);
}
// Partial 3: Stream cost on every message batch
emitCostIfChanged(sid);
});
sidecarManager.onStatus(sessionId as string, (sid: string, status: string, error?: string) => {
@ -35,19 +63,7 @@ export function createAgentHandlers(
} catch (err) {
console.error("[agent.status] forward error:", err);
}
const sessions = sidecarManager.listSessions();
const session = sessions.find((s: Record<string, unknown>) => s.sessionId === sid);
if (session) {
try {
(sendToWebview.send as Record<string, Function>)["agent.cost"]({
sessionId: sid,
costUsd: session.costUsd,
inputTokens: session.inputTokens,
outputTokens: session.outputTokens,
});
} catch { /* ignore */ }
}
emitCostIfChanged(sid);
});
}
@ -122,9 +138,22 @@ export function createAgentHandlers(
sessionDb.saveMessages(messages.map((m) => ({
sessionId: m.sessionId, msgId: m.msgId, role: m.role,
content: m.content, toolName: m.toolName, toolInput: m.toolInput,
timestamp: m.timestamp, costUsd: (m.costUsd as number) ?? 0,
timestamp: m.timestamp, seqId: (m.seqId as number) ?? 0,
costUsd: (m.costUsd as number) ?? 0,
inputTokens: (m.inputTokens as number) ?? 0, outputTokens: (m.outputTokens as number) ?? 0,
})));
// Partial 1: Index each new message in FTS5 search
if (searchDb) {
for (const m of messages) {
try {
searchDb.indexMessage(
String(m.sessionId ?? ""),
String(m.role ?? ""),
String(m.content ?? ""),
);
} catch { /* non-critical — don't fail the save */ }
}
}
return { ok: true };
} catch (err) {
console.error("[session.messages.save]", err);

View file

@ -16,6 +16,7 @@ import type { RelayClient } from "../relay-client.ts";
import type { SidecarManager } from "../sidecar-manager.ts";
import { checkForUpdates, getLastCheckTimestamp } from "../updater.ts";
import type { TelemetryManager } from "../telemetry.ts";
import { getRpcCallCount, getDroppedEvents } from "../rpc-stats.ts";
// ── Helpers ─────────────────────────────────────────────────────────────────
@ -199,8 +200,8 @@ export function createMiscHandlers(deps: MiscDeps) {
ptyConnected: ptyClient.isConnected,
relayConnections: relayClient.listMachines().filter((m) => m.status === "connected").length,
activeSidecars: sidecarManager.listSessions().filter((s) => s.status === "running").length,
rpcCallCount: 0,
droppedEvents: 0,
rpcCallCount: getRpcCallCount(),
droppedEvents: getDroppedEvents(),
}),
// ── Telemetry ───────────────────────────────────────────────────────

View file

@ -29,6 +29,7 @@ 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";
@ -74,7 +75,7 @@ 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);
const agentHandlers = createAgentHandlers(sidecarManager, sessionDb, rpcRef, searchDb);
const btmsgHandlers = createBtmsgHandlers(btmsgDb, rpcRef);
const bttaskHandlers = createBttaskHandlers(bttaskDb, rpcRef);
const searchHandlers = createSearchHandlers(searchDb);
@ -89,12 +90,23 @@ const miscHandlers = createMiscHandlers({
// 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 rpc = BrowserView.defineRPC<PtyRPCSchema>({
maxRequestTime: 120_000,
handlers: {
requests: {
const allHandlers = withRpcCounting({
...ptyHandlers,
...filesHandlers,
...settingsHandlers,
@ -107,6 +119,13 @@ const rpc = BrowserView.defineRPC<PtyRPCSchema>({
...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 }) => {
@ -202,13 +221,6 @@ relayClient.onStatus((machineId, status, error) => {
// ── App window ────────────────────────────────────────────────────────────
async function getMainViewUrl(): Promise<string> {
// TEMPORARY: load resize test stub via Vite dev server (keeps RPC bridge)
const RESIZE_TEST = false; // DISABLED — real app mode
if (RESIZE_TEST) {
const testUrl = DEV_SERVER_URL + "/resize-test.html";
console.log(`[RESIZE_TEST] Loading stub via Vite: ${testUrl}`);
return testUrl;
}
const channel = await Updater.localInfo.channel();
if (channel === "dev") {
try {
@ -224,6 +236,14 @@ async function getMainViewUrl(): Promise<string> {
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);

View file

@ -0,0 +1,23 @@
/**
* Simple RPC call counter for diagnostics.
* Incremented on each RPC request, read by diagnostics.stats handler.
*/
let _rpcCallCount = 0;
let _droppedEvents = 0;
export function incrementRpcCallCount(): void {
_rpcCallCount++;
}
export function incrementDroppedEvents(): void {
_droppedEvents++;
}
export function getRpcCallCount(): number {
return _rpcCallCount;
}
export function getDroppedEvents(): number {
return _droppedEvents;
}

View file

@ -39,6 +39,7 @@ export interface StoredMessage {
toolName?: string;
toolInput?: string;
timestamp: number;
seqId: number;
costUsd: number;
inputTokens: number;
outputTokens: number;
@ -72,6 +73,7 @@ CREATE TABLE IF NOT EXISTS agent_messages (
tool_name TEXT,
tool_input TEXT,
timestamp INTEGER NOT NULL,
seq_id INTEGER NOT NULL DEFAULT 0,
cost_usd REAL NOT NULL DEFAULT 0,
input_tokens INTEGER NOT NULL DEFAULT 0,
output_tokens INTEGER NOT NULL DEFAULT 0,
@ -91,6 +93,17 @@ export class SessionDb {
constructor() {
this.db = openDb(DB_PATH, { foreignKeys: true });
this.db.exec(SESSION_SCHEMA);
this.migrate();
}
/** Run schema migrations for existing DBs. */
private migrate(): void {
// Add seq_id column if missing (for DBs created before this field existed)
try {
this.db.run("ALTER TABLE agent_messages ADD COLUMN seq_id INTEGER NOT NULL DEFAULT 0");
} catch {
// Column already exists — expected
}
}
// ── Sessions ─────────────────────────────────────────────────────────────
@ -212,8 +225,8 @@ export class SessionDb {
.query(
`INSERT INTO agent_messages
(session_id, msg_id, role, content, tool_name, tool_input,
timestamp, cost_usd, input_tokens, output_tokens)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)
timestamp, seq_id, cost_usd, input_tokens, output_tokens)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)
ON CONFLICT(session_id, msg_id) DO NOTHING`
)
.run(
@ -224,6 +237,7 @@ export class SessionDb {
m.toolName ?? null,
m.toolInput ?? null,
m.timestamp,
m.seqId ?? 0,
m.costUsd,
m.inputTokens,
m.outputTokens
@ -234,8 +248,8 @@ export class SessionDb {
const stmt = this.db.prepare(
`INSERT INTO agent_messages
(session_id, msg_id, role, content, tool_name, tool_input,
timestamp, cost_usd, input_tokens, output_tokens)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)
timestamp, seq_id, cost_usd, input_tokens, output_tokens)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)
ON CONFLICT(session_id, msg_id) DO NOTHING`
);
@ -249,6 +263,7 @@ export class SessionDb {
m.toolName ?? null,
m.toolInput ?? null,
m.timestamp,
m.seqId ?? 0,
m.costUsd,
m.inputTokens,
m.outputTokens
@ -269,6 +284,7 @@ export class SessionDb {
tool_name: string | null;
tool_input: string | null;
timestamp: number;
seq_id: number;
cost_usd: number;
input_tokens: number;
output_tokens: number;
@ -289,6 +305,7 @@ export class SessionDb {
toolName: r.tool_name ?? undefined,
toolInput: r.tool_input ?? undefined,
timestamp: r.timestamp,
seqId: r.seq_id ?? 0,
costUsd: r.cost_usd,
inputTokens: r.input_tokens,
outputTokens: r.output_tokens,

View file

@ -258,6 +258,9 @@
keybindingStore.on("group4", () => setActiveGroup(getGroups()[3]?.id));
keybindingStore.on("minimize", () => handleMinimize());
// Bug 3: Persist window frame on resize (debounced inside saveWindowFrame)
window.addEventListener("resize", saveWindowFrame);
function handleSearchShortcut(e: KeyboardEvent) {
if (e.ctrlKey && e.shiftKey && e.key === "F") {
e.preventDefault();
@ -316,7 +319,7 @@
clearInterval(sessionId);
document.removeEventListener("keydown", handleSearchShortcut);
window.removeEventListener("palette-command", handlePaletteCommand);
// Native resize — no JS listeners to clean
window.removeEventListener("resize", saveWindowFrame);
};
});
</script>

View file

@ -549,6 +549,9 @@ async function _startAgentInner(
if (!result.ok) {
sessions[sessionId].status = 'error';
sessions[sessionId].error = result.error;
// Bug 1: Clear the dead session so the next send starts fresh
projectSessionMap.delete(projectId);
clearStallTimer(sessionId);
}
return result;

View file

@ -15,13 +15,9 @@ export interface Notification {
// ── State ─────────────────────────────────────────────────────────────────
let notifications = $state<Notification[]>([
{ id: 1, message: 'Agent completed: wake scheduler implemented', type: 'success', time: '2m ago' },
{ id: 2, message: 'Context pressure: 78% on agent-orchestrator', type: 'warning', time: '5m ago' },
{ id: 3, message: 'PTY daemon connected', type: 'info', time: '12m ago' },
]);
let notifications = $state<Notification[]>([]);
let nextId = $state(100);
let nextId = $state(1);
// ── Public API ────────────────────────────────────────────────────────────