Multi-machine relay: - relay-client.ts: WebSocket client for agor-relay with token auth, exponential backoff (1s-30s), TCP probe, heartbeat (15s ping) - machines-store.svelte.ts: remote machine state tracking - RemoteMachinesSettings.svelte: machine list, add/connect/disconnect UI - 7 RPC types (remote.connect/disconnect/list/send/status + events) Telemetry: - telemetry.ts: OTEL spans + OTLP/HTTP export to Tempo, controlled by AGOR_OTLP_ENDPOINT env var - telemetry-bridge.ts: tel.info/warn/error frontend convenience API - telemetry.log RPC for frontend→Bun tracing
1075 lines
36 KiB
TypeScript
1075 lines
36 KiB
TypeScript
import path from "path";
|
|
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 { 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";
|
|
|
|
/** Current app version — sourced from electrobun.config.ts at build time. */
|
|
const APP_VERSION = "0.0.1";
|
|
|
|
const DEV_SERVER_PORT = 9760; // Project convention: 9700+ range
|
|
const DEV_SERVER_URL = `http://localhost:${DEV_SERVER_PORT}`;
|
|
|
|
// ── PTY daemon client ────────────────────────────────────────────────────────
|
|
|
|
const ptyClient = new PtyClient();
|
|
const sidecarManager = new SidecarManager();
|
|
const searchDb = new SearchDb();
|
|
const relayClient = new RelayClient();
|
|
const PLUGINS_DIR = join(homedir(), ".config", "agor", "plugins");
|
|
|
|
// Initialize telemetry (console-only unless AGOR_OTLP_ENDPOINT is set)
|
|
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}`);
|
|
console.error("[agor-ptyd] Terminals will not work. Start agor-ptyd and restart the app.");
|
|
}
|
|
}
|
|
}
|
|
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 };
|
|
}
|
|
|
|
// ── RPC definition ────────────────────────────────────────────────────────────
|
|
|
|
const rpc = BrowserView.defineRPC<PtyRPCSchema>({
|
|
maxRequestTime: 15_000,
|
|
handlers: {
|
|
requests: {
|
|
"pty.create": async ({ sessionId, cols, rows, cwd }) => {
|
|
if (!ptyClient.isConnected) {
|
|
return { ok: false, error: "PTY daemon not connected" };
|
|
}
|
|
try {
|
|
ptyClient.createSession({ id: sessionId, cols, rows, cwd });
|
|
return { ok: true };
|
|
} catch (err) {
|
|
const error = err instanceof Error ? err.message : String(err);
|
|
console.error(`[pty.create] ${sessionId}: ${error}`);
|
|
return { ok: false, error };
|
|
}
|
|
},
|
|
|
|
"pty.write": ({ sessionId, data }) => {
|
|
if (!ptyClient.isConnected) {
|
|
console.error(`[pty.write] ${sessionId}: daemon not connected`);
|
|
return { ok: false };
|
|
}
|
|
try {
|
|
ptyClient.writeInput(sessionId, data);
|
|
return { ok: true };
|
|
} catch (err) {
|
|
const error = err instanceof Error ? err.message : String(err);
|
|
console.error(`[pty.write] ${sessionId}: ${error}`);
|
|
return { ok: false };
|
|
}
|
|
},
|
|
|
|
"pty.resize": ({ sessionId, cols, rows }) => {
|
|
if (!ptyClient.isConnected) return { ok: true };
|
|
try {
|
|
ptyClient.resize(sessionId, cols, rows);
|
|
} catch (err) {
|
|
console.error(`[pty.resize] ${sessionId}:`, err);
|
|
}
|
|
return { ok: true };
|
|
},
|
|
|
|
"pty.unsubscribe": ({ sessionId }) => {
|
|
try {
|
|
ptyClient.unsubscribe(sessionId);
|
|
} catch (err) {
|
|
console.error(`[pty.unsubscribe] ${sessionId}:`, err);
|
|
}
|
|
return { ok: true };
|
|
},
|
|
|
|
"pty.close": ({ sessionId }) => {
|
|
try {
|
|
ptyClient.closeSession(sessionId);
|
|
} catch (err) {
|
|
console.error(`[pty.close] ${sessionId}:`, err);
|
|
}
|
|
return { ok: true };
|
|
},
|
|
|
|
// ── Settings handlers ─────────────────────────────────────────────────
|
|
|
|
"settings.get": ({ key }) => {
|
|
try {
|
|
return { value: settingsDb.getSetting(key) };
|
|
} catch (err) {
|
|
console.error("[settings.get]", err);
|
|
return { value: null };
|
|
}
|
|
},
|
|
|
|
"settings.set": ({ key, value }) => {
|
|
try {
|
|
settingsDb.setSetting(key, value);
|
|
return { ok: true };
|
|
} catch (err) {
|
|
console.error("[settings.set]", err);
|
|
return { ok: false };
|
|
}
|
|
},
|
|
|
|
"settings.getAll": () => {
|
|
try {
|
|
return { settings: settingsDb.getAll() };
|
|
} catch (err) {
|
|
console.error("[settings.getAll]", err);
|
|
return { settings: {} };
|
|
}
|
|
},
|
|
|
|
"settings.getProjects": () => {
|
|
try {
|
|
const projects = settingsDb.listProjects().map((p) => ({
|
|
id: p.id,
|
|
config: JSON.stringify(p),
|
|
}));
|
|
return { projects };
|
|
} catch (err) {
|
|
console.error("[settings.getProjects]", err);
|
|
return { projects: [] };
|
|
}
|
|
},
|
|
|
|
"settings.setProject": ({ id, config }) => {
|
|
try {
|
|
const parsed = JSON.parse(config);
|
|
settingsDb.setProject(id, { id, ...parsed });
|
|
return { ok: true };
|
|
} catch (err) {
|
|
console.error("[settings.setProject]", err);
|
|
return { ok: false };
|
|
}
|
|
},
|
|
|
|
// ── Custom Themes handlers ───────────────────────────────────────────
|
|
|
|
"themes.getCustom": () => {
|
|
try {
|
|
return { themes: settingsDb.getCustomThemes() };
|
|
} catch (err) {
|
|
console.error("[themes.getCustom]", err);
|
|
return { themes: [] };
|
|
}
|
|
},
|
|
|
|
"themes.saveCustom": ({ id, name, palette }) => {
|
|
try {
|
|
settingsDb.saveCustomTheme(id, name, palette);
|
|
return { ok: true };
|
|
} catch (err) {
|
|
console.error("[themes.saveCustom]", err);
|
|
return { ok: false };
|
|
}
|
|
},
|
|
|
|
"themes.deleteCustom": ({ id }) => {
|
|
try {
|
|
settingsDb.deleteCustomTheme(id);
|
|
return { ok: true };
|
|
} catch (err) {
|
|
console.error("[themes.deleteCustom]", err);
|
|
return { ok: false };
|
|
}
|
|
},
|
|
|
|
// ── File I/O handlers ────────────────────────────────────────────────
|
|
|
|
"files.list": async ({ path: dirPath }) => {
|
|
try {
|
|
const dirents = fs.readdirSync(dirPath, { withFileTypes: true });
|
|
const entries = dirents
|
|
.filter((d) => !d.name.startsWith("."))
|
|
.map((d) => {
|
|
let size = 0;
|
|
if (d.isFile()) {
|
|
try {
|
|
size = fs.statSync(path.join(dirPath, d.name)).size;
|
|
} catch { /* ignore stat errors */ }
|
|
}
|
|
return {
|
|
name: d.name,
|
|
type: (d.isDirectory() ? "dir" : "file") as "file" | "dir",
|
|
size,
|
|
};
|
|
})
|
|
.sort((a, b) => {
|
|
// Directories first, then alphabetical
|
|
if (a.type !== b.type) return a.type === "dir" ? -1 : 1;
|
|
return a.name.localeCompare(b.name);
|
|
});
|
|
return { entries };
|
|
} catch (err) {
|
|
const error = err instanceof Error ? err.message : String(err);
|
|
console.error("[files.list]", error);
|
|
return { entries: [], error };
|
|
}
|
|
},
|
|
|
|
"files.read": async ({ path: filePath }) => {
|
|
try {
|
|
const stat = fs.statSync(filePath);
|
|
const MAX_SIZE = 10 * 1024 * 1024; // 10MB
|
|
if (stat.size > MAX_SIZE) {
|
|
return { encoding: "utf8" as const, size: stat.size, error: `File too large (${(stat.size / 1024 / 1024).toFixed(1)}MB). Maximum is 10MB.` };
|
|
}
|
|
|
|
// Detect binary by reading first 8KB
|
|
const buf = Buffer.alloc(Math.min(8192, stat.size));
|
|
const fd = fs.openSync(filePath, "r");
|
|
fs.readSync(fd, buf, 0, buf.length, 0);
|
|
fs.closeSync(fd);
|
|
|
|
const isBinary = buf.includes(0); // null byte = binary
|
|
if (isBinary) {
|
|
const content = fs.readFileSync(filePath).toString("base64");
|
|
return { content, encoding: "base64" as const, size: stat.size };
|
|
}
|
|
|
|
const content = fs.readFileSync(filePath, "utf8");
|
|
return { content, encoding: "utf8" as const, size: stat.size };
|
|
} catch (err) {
|
|
const error = err instanceof Error ? err.message : String(err);
|
|
console.error("[files.read]", error);
|
|
return { encoding: "utf8" as const, size: 0, error };
|
|
}
|
|
},
|
|
|
|
"files.write": async ({ path: filePath, content }) => {
|
|
try {
|
|
fs.writeFileSync(filePath, content, "utf8");
|
|
return { ok: true };
|
|
} catch (err) {
|
|
const error = err instanceof Error ? err.message : String(err);
|
|
console.error("[files.write]", error);
|
|
return { ok: false, error };
|
|
}
|
|
},
|
|
|
|
// ── Groups handlers ──────────────────────────────────────────────────
|
|
|
|
"groups.list": () => {
|
|
try {
|
|
return { groups: settingsDb.listGroups() };
|
|
} catch (err) {
|
|
console.error("[groups.list]", err);
|
|
return { groups: [] };
|
|
}
|
|
},
|
|
|
|
// ── 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}` };
|
|
}
|
|
|
|
// Determine the authoritative main repo path
|
|
const mainRepoPath = source.mainRepoPath ?? source.cwd;
|
|
|
|
// Count existing clones
|
|
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;
|
|
// Fix #8: Use UUID suffix to prevent race conditions between concurrent clones
|
|
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 control handlers ──────────────────────────────────────────
|
|
|
|
"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();
|
|
// Heuristic: if window is already near the screen edge, unmaximize
|
|
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 };
|
|
}
|
|
},
|
|
|
|
// ── Keybinding handlers ──────────────────────────────────────────────
|
|
|
|
"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 };
|
|
}
|
|
},
|
|
|
|
// ── Agent handlers ──────────────────────────────────────────────────
|
|
|
|
"agent.start": ({ sessionId, provider, prompt, cwd, model, systemPrompt, maxTurns, permissionMode, claudeConfigDir, extraEnv }) => {
|
|
try {
|
|
const result = sidecarManager.startSession(sessionId, provider, prompt, {
|
|
cwd,
|
|
model,
|
|
systemPrompt,
|
|
maxTurns,
|
|
permissionMode,
|
|
claudeConfigDir,
|
|
extraEnv,
|
|
});
|
|
|
|
if (result.ok) {
|
|
// Forward sidecar messages to webview
|
|
sidecarManager.onMessage(sessionId, (sid, messages) => {
|
|
try {
|
|
rpc.send["agent.message"]({ sessionId: sid, messages });
|
|
} catch (err) {
|
|
console.error("[agent.message] forward error:", err);
|
|
}
|
|
});
|
|
|
|
sidecarManager.onStatus(sessionId, (sid, status, error) => {
|
|
try {
|
|
rpc.send["agent.status"]({ sessionId: sid, status, error });
|
|
} catch (err) {
|
|
console.error("[agent.status] forward error:", err);
|
|
}
|
|
|
|
// Send cost update on status change
|
|
const sessions = sidecarManager.listSessions();
|
|
const session = sessions.find((s) => s.sessionId === sid);
|
|
if (session) {
|
|
try {
|
|
rpc.send["agent.cost"]({
|
|
sessionId: sid,
|
|
costUsd: session.costUsd,
|
|
inputTokens: session.inputTokens,
|
|
outputTokens: session.outputTokens,
|
|
});
|
|
} catch { /* ignore */ }
|
|
}
|
|
});
|
|
}
|
|
|
|
return result;
|
|
} catch (err) {
|
|
const error = err instanceof Error ? err.message : String(err);
|
|
console.error("[agent.start]", err);
|
|
return { ok: false, error };
|
|
}
|
|
},
|
|
|
|
"agent.stop": ({ sessionId }) => {
|
|
try {
|
|
return sidecarManager.stopSession(sessionId);
|
|
} catch (err) {
|
|
const error = err instanceof Error ? err.message : String(err);
|
|
console.error("[agent.stop]", err);
|
|
return { ok: false, error };
|
|
}
|
|
},
|
|
|
|
"agent.prompt": ({ sessionId, prompt }) => {
|
|
try {
|
|
return sidecarManager.writePrompt(sessionId, prompt);
|
|
} catch (err) {
|
|
const error = err instanceof Error ? err.message : String(err);
|
|
console.error("[agent.prompt]", err);
|
|
return { ok: false, error };
|
|
}
|
|
},
|
|
|
|
"agent.list": () => {
|
|
try {
|
|
return { sessions: sidecarManager.listSessions() };
|
|
} catch (err) {
|
|
console.error("[agent.list]", err);
|
|
return { sessions: [] };
|
|
}
|
|
},
|
|
|
|
// ── Session persistence handlers ──────────────────────────────────
|
|
|
|
"session.save": ({ projectId, sessionId, provider, status, costUsd, inputTokens, outputTokens, model, error, createdAt, updatedAt }) => {
|
|
try {
|
|
sessionDb.saveSession({
|
|
projectId, sessionId, provider, status, costUsd,
|
|
inputTokens, outputTokens, model, error, createdAt, updatedAt,
|
|
});
|
|
return { ok: true };
|
|
} catch (err) {
|
|
console.error("[session.save]", err);
|
|
return { ok: false };
|
|
}
|
|
},
|
|
|
|
"session.load": ({ projectId }) => {
|
|
try {
|
|
return { session: sessionDb.loadSession(projectId) };
|
|
} catch (err) {
|
|
console.error("[session.load]", err);
|
|
return { session: null };
|
|
}
|
|
},
|
|
|
|
"session.list": ({ projectId }) => {
|
|
try {
|
|
return { sessions: sessionDb.listSessionsByProject(projectId) };
|
|
} catch (err) {
|
|
console.error("[session.list]", err);
|
|
return { sessions: [] };
|
|
}
|
|
},
|
|
|
|
"session.messages.save": ({ messages }) => {
|
|
try {
|
|
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 ?? 0,
|
|
inputTokens: m.inputTokens ?? 0, outputTokens: m.outputTokens ?? 0,
|
|
})));
|
|
return { ok: true };
|
|
} catch (err) {
|
|
console.error("[session.messages.save]", err);
|
|
return { ok: false };
|
|
}
|
|
},
|
|
|
|
"session.messages.load": ({ sessionId }) => {
|
|
try {
|
|
return { messages: sessionDb.loadMessages(sessionId) };
|
|
} catch (err) {
|
|
console.error("[session.messages.load]", err);
|
|
return { messages: [] };
|
|
}
|
|
},
|
|
|
|
// ── btmsg handlers ────────────────────────────────────────────────
|
|
|
|
"btmsg.registerAgent": ({ id, name, role, groupId, tier, model }) => {
|
|
try {
|
|
btmsgDb.registerAgent(id, name, role, groupId, tier, model);
|
|
return { ok: true };
|
|
} catch (err) {
|
|
console.error("[btmsg.registerAgent]", err);
|
|
return { ok: false };
|
|
}
|
|
},
|
|
|
|
"btmsg.getAgents": ({ groupId }) => {
|
|
try {
|
|
return { agents: btmsgDb.getAgents(groupId) };
|
|
} catch (err) {
|
|
console.error("[btmsg.getAgents]", err);
|
|
return { agents: [] };
|
|
}
|
|
},
|
|
|
|
"btmsg.sendMessage": ({ fromAgent, toAgent, content }) => {
|
|
try {
|
|
const messageId = btmsgDb.sendMessage(fromAgent, toAgent, content);
|
|
return { ok: true, messageId };
|
|
} catch (err) {
|
|
const error = err instanceof Error ? err.message : String(err);
|
|
console.error("[btmsg.sendMessage]", err);
|
|
return { ok: false, error };
|
|
}
|
|
},
|
|
|
|
"btmsg.listMessages": ({ agentId, otherId, limit }) => {
|
|
try {
|
|
return { messages: btmsgDb.listMessages(agentId, otherId, limit ?? 50) };
|
|
} catch (err) {
|
|
console.error("[btmsg.listMessages]", err);
|
|
return { messages: [] };
|
|
}
|
|
},
|
|
|
|
"btmsg.markRead": ({ agentId, messageIds }) => {
|
|
try {
|
|
btmsgDb.markRead(agentId, messageIds);
|
|
return { ok: true };
|
|
} catch (err) {
|
|
console.error("[btmsg.markRead]", err);
|
|
return { ok: false };
|
|
}
|
|
},
|
|
|
|
"btmsg.listChannels": ({ groupId }) => {
|
|
try {
|
|
return { channels: btmsgDb.listChannels(groupId) };
|
|
} catch (err) {
|
|
console.error("[btmsg.listChannels]", err);
|
|
return { channels: [] };
|
|
}
|
|
},
|
|
|
|
"btmsg.createChannel": ({ name, groupId, createdBy }) => {
|
|
try {
|
|
const channelId = btmsgDb.createChannel(name, groupId, createdBy);
|
|
return { ok: true, channelId };
|
|
} catch (err) {
|
|
console.error("[btmsg.createChannel]", err);
|
|
return { ok: false };
|
|
}
|
|
},
|
|
|
|
"btmsg.getChannelMessages": ({ channelId, limit }) => {
|
|
try {
|
|
return { messages: btmsgDb.getChannelMessages(channelId, limit ?? 100) };
|
|
} catch (err) {
|
|
console.error("[btmsg.getChannelMessages]", err);
|
|
return { messages: [] };
|
|
}
|
|
},
|
|
|
|
"btmsg.sendChannelMessage": ({ channelId, fromAgent, content }) => {
|
|
try {
|
|
const messageId = btmsgDb.sendChannelMessage(channelId, fromAgent, content);
|
|
return { ok: true, messageId };
|
|
} catch (err) {
|
|
console.error("[btmsg.sendChannelMessage]", err);
|
|
return { ok: false };
|
|
}
|
|
},
|
|
|
|
"btmsg.heartbeat": ({ agentId }) => {
|
|
try {
|
|
btmsgDb.heartbeat(agentId);
|
|
return { ok: true };
|
|
} catch (err) {
|
|
console.error("[btmsg.heartbeat]", err);
|
|
return { ok: false };
|
|
}
|
|
},
|
|
|
|
"btmsg.getDeadLetters": ({ limit }) => {
|
|
try {
|
|
return { letters: btmsgDb.getDeadLetters(limit ?? 50) };
|
|
} catch (err) {
|
|
console.error("[btmsg.getDeadLetters]", err);
|
|
return { letters: [] };
|
|
}
|
|
},
|
|
|
|
"btmsg.logAudit": ({ agentId, eventType, detail }) => {
|
|
try {
|
|
btmsgDb.logAudit(agentId, eventType, detail);
|
|
return { ok: true };
|
|
} catch (err) {
|
|
console.error("[btmsg.logAudit]", err);
|
|
return { ok: false };
|
|
}
|
|
},
|
|
|
|
"btmsg.getAuditLog": ({ limit }) => {
|
|
try {
|
|
return { entries: btmsgDb.getAuditLog(limit ?? 100) };
|
|
} catch (err) {
|
|
console.error("[btmsg.getAuditLog]", err);
|
|
return { entries: [] };
|
|
}
|
|
},
|
|
|
|
// ── bttask handlers ───────────────────────────────────────────────
|
|
|
|
"bttask.listTasks": ({ groupId }) => {
|
|
try {
|
|
return { tasks: bttaskDb.listTasks(groupId) };
|
|
} catch (err) {
|
|
console.error("[bttask.listTasks]", err);
|
|
return { tasks: [] };
|
|
}
|
|
},
|
|
|
|
"bttask.createTask": ({ title, description, priority, groupId, createdBy, assignedTo }) => {
|
|
try {
|
|
const taskId = bttaskDb.createTask(title, description, priority, groupId, createdBy, assignedTo);
|
|
return { ok: true, taskId };
|
|
} catch (err) {
|
|
const error = err instanceof Error ? err.message : String(err);
|
|
console.error("[bttask.createTask]", err);
|
|
return { ok: false, error };
|
|
}
|
|
},
|
|
|
|
"bttask.updateTaskStatus": ({ taskId, status, expectedVersion }) => {
|
|
try {
|
|
const newVersion = bttaskDb.updateTaskStatus(taskId, status, expectedVersion);
|
|
return { ok: true, newVersion };
|
|
} catch (err) {
|
|
const error = err instanceof Error ? err.message : String(err);
|
|
console.error("[bttask.updateTaskStatus]", err);
|
|
return { ok: false, error };
|
|
}
|
|
},
|
|
|
|
"bttask.deleteTask": ({ taskId }) => {
|
|
try {
|
|
bttaskDb.deleteTask(taskId);
|
|
return { ok: true };
|
|
} catch (err) {
|
|
console.error("[bttask.deleteTask]", err);
|
|
return { ok: false };
|
|
}
|
|
},
|
|
|
|
"bttask.addComment": ({ taskId, agentId, content }) => {
|
|
try {
|
|
const commentId = bttaskDb.addComment(taskId, agentId, content);
|
|
return { ok: true, commentId };
|
|
} catch (err) {
|
|
console.error("[bttask.addComment]", err);
|
|
return { ok: false };
|
|
}
|
|
},
|
|
|
|
"bttask.listComments": ({ taskId }) => {
|
|
try {
|
|
return { comments: bttaskDb.listComments(taskId) };
|
|
} catch (err) {
|
|
console.error("[bttask.listComments]", err);
|
|
return { comments: [] };
|
|
}
|
|
},
|
|
|
|
"bttask.reviewQueueCount": ({ groupId }) => {
|
|
try {
|
|
return { count: bttaskDb.reviewQueueCount(groupId) };
|
|
} catch (err) {
|
|
console.error("[bttask.reviewQueueCount]", err);
|
|
return { count: 0 };
|
|
}
|
|
},
|
|
|
|
// ── Search handlers ──────────────────────────────────────────────────
|
|
|
|
"search.query": ({ query, limit }) => {
|
|
try {
|
|
const results = searchDb.searchAll(query, limit ?? 20);
|
|
return { results };
|
|
} catch (err) {
|
|
console.error("[search.query]", err);
|
|
return { results: [] };
|
|
}
|
|
},
|
|
|
|
"search.indexMessage": ({ sessionId, role, content }) => {
|
|
try {
|
|
searchDb.indexMessage(sessionId, role, content);
|
|
return { ok: true };
|
|
} catch (err) {
|
|
console.error("[search.indexMessage]", err);
|
|
return { ok: false };
|
|
}
|
|
},
|
|
|
|
"search.rebuild": () => {
|
|
try {
|
|
searchDb.rebuildIndex();
|
|
return { ok: true };
|
|
} catch (err) {
|
|
console.error("[search.rebuild]", err);
|
|
return { ok: false };
|
|
}
|
|
},
|
|
|
|
// ── Plugin handlers ──────────────────────────────────────────────────
|
|
|
|
"plugin.discover": () => {
|
|
try {
|
|
const plugins: Array<{
|
|
id: string; name: string; version: string;
|
|
description: string; main: string; permissions: string[];
|
|
}> = [];
|
|
|
|
if (!fs.existsSync(PLUGINS_DIR)) return { plugins };
|
|
|
|
const entries = fs.readdirSync(PLUGINS_DIR, { withFileTypes: true });
|
|
for (const entry of entries) {
|
|
if (!entry.isDirectory()) continue;
|
|
const manifestPath = join(PLUGINS_DIR, entry.name, "plugin.json");
|
|
if (!fs.existsSync(manifestPath)) continue;
|
|
|
|
try {
|
|
const raw = fs.readFileSync(manifestPath, "utf-8");
|
|
const manifest = JSON.parse(raw);
|
|
plugins.push({
|
|
id: manifest.id ?? entry.name,
|
|
name: manifest.name ?? entry.name,
|
|
version: manifest.version ?? "0.0.0",
|
|
description: manifest.description ?? "",
|
|
main: manifest.main ?? "index.js",
|
|
permissions: Array.isArray(manifest.permissions) ? manifest.permissions : [],
|
|
});
|
|
} catch (parseErr) {
|
|
console.error(`[plugin.discover] Bad manifest in ${entry.name}:`, parseErr);
|
|
}
|
|
}
|
|
|
|
return { plugins };
|
|
} catch (err) {
|
|
console.error("[plugin.discover]", err);
|
|
return { plugins: [] };
|
|
}
|
|
},
|
|
|
|
"plugin.readFile": ({ pluginId, filePath }) => {
|
|
try {
|
|
// Path traversal protection: resolve and verify within plugins dir
|
|
const pluginDir = join(PLUGINS_DIR, pluginId);
|
|
const resolved = path.resolve(pluginDir, filePath);
|
|
if (!resolved.startsWith(pluginDir + path.sep) && resolved !== pluginDir) {
|
|
return { ok: false, content: "", error: "Path traversal blocked" };
|
|
}
|
|
if (!fs.existsSync(resolved)) {
|
|
return { ok: false, content: "", error: "File not found" };
|
|
}
|
|
const content = fs.readFileSync(resolved, "utf-8");
|
|
return { ok: true, content };
|
|
} catch (err) {
|
|
const error = err instanceof Error ? err.message : String(err);
|
|
console.error("[plugin.readFile]", err);
|
|
return { ok: false, content: "", error };
|
|
}
|
|
},
|
|
|
|
// ── Updater handlers ──────────────────────────────────────────────────
|
|
|
|
"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": () => {
|
|
return {
|
|
version: APP_VERSION,
|
|
lastCheck: getLastCheckTimestamp(),
|
|
};
|
|
},
|
|
|
|
// ── Remote machine (relay) handlers ──────────────────────────────────
|
|
|
|
"remote.connect": async ({ url, token, label }) => {
|
|
try {
|
|
const machineId = await relayClient.connect(url, token, label);
|
|
return { ok: true, machineId };
|
|
} catch (err) {
|
|
const error = err instanceof Error ? err.message : String(err);
|
|
console.error("[remote.connect]", err);
|
|
return { ok: false, error };
|
|
}
|
|
},
|
|
|
|
"remote.disconnect": ({ machineId }) => {
|
|
try {
|
|
relayClient.disconnect(machineId);
|
|
return { ok: true };
|
|
} catch (err) {
|
|
const error = err instanceof Error ? err.message : String(err);
|
|
console.error("[remote.disconnect]", err);
|
|
return { ok: false, error };
|
|
}
|
|
},
|
|
|
|
"remote.list": () => {
|
|
try {
|
|
return { machines: relayClient.listMachines() };
|
|
} catch (err) {
|
|
console.error("[remote.list]", err);
|
|
return { machines: [] };
|
|
}
|
|
},
|
|
|
|
"remote.send": ({ machineId, command, payload }) => {
|
|
try {
|
|
relayClient.sendCommand(machineId, command, payload);
|
|
return { ok: true };
|
|
} catch (err) {
|
|
const error = err instanceof Error ? err.message : String(err);
|
|
console.error("[remote.send]", err);
|
|
return { ok: false, error };
|
|
}
|
|
},
|
|
|
|
"remote.status": ({ machineId }) => {
|
|
try {
|
|
const info = relayClient.getStatus(machineId);
|
|
if (!info) {
|
|
return { status: "disconnected" as const, latencyMs: null, error: "Machine not found" };
|
|
}
|
|
return { status: info.status, latencyMs: info.latencyMs };
|
|
} catch (err) {
|
|
const error = err instanceof Error ? err.message : String(err);
|
|
console.error("[remote.status]", err);
|
|
return { status: "error" as const, latencyMs: null, error };
|
|
}
|
|
},
|
|
|
|
// ── Telemetry handler ────────────────────────────────────────────────
|
|
|
|
"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: {},
|
|
},
|
|
});
|
|
|
|
// ── 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";
|
|
}
|
|
|
|
// Connect to daemon (non-blocking — window opens regardless).
|
|
connectToDaemon();
|
|
|
|
const url = await getMainViewUrl();
|
|
|
|
// Restore persisted window frame if available
|
|
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", // "hidden" breaks clicks on WebKitGTK — testing with 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!");
|