feat(electrobun): multi-machine relay + OTEL telemetry

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
This commit is contained in:
Hibryda 2026-03-22 01:46:03 +01:00
parent ec30c69c3e
commit 88206205fe
11 changed files with 1458 additions and 15 deletions

View file

@ -10,9 +10,15 @@ 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}`;
@ -21,8 +27,12 @@ const DEV_SERVER_URL = `http://localhost:${DEV_SERVER_PORT}`;
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 {
@ -874,6 +884,103 @@ const rpc = BrowserView.defineRPC<PtyRPCSchema>({
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: {},
@ -900,6 +1007,29 @@ ptyClient.on("session_closed", (msg) => {
}
});
// ── 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> {

View file

@ -0,0 +1,343 @@
/**
* WebSocket client for connecting to agor-relay instances.
*
* Features:
* - Token-based auth handshake (Bearer header)
* - Exponential backoff reconnection (1s30s cap)
* - TCP probe before full WS upgrade on reconnect
* - Per-connection command routing
* - Event forwarding to webview via callback
*/
import { randomUUID } from "crypto";
import { Socket } from "net";
// ── Types ──────────────────────────────────────────────────────────────────
export type ConnectionStatus = "connecting" | "connected" | "disconnected" | "error";
export interface RelayCommand {
id: string;
type: string;
payload: Record<string, unknown>;
}
export interface RelayEvent {
type: string;
sessionId?: string;
machineId?: string;
payload?: unknown;
}
export type EventCallback = (machineId: string, event: RelayEvent) => void;
export type StatusCallback = (machineId: string, status: ConnectionStatus, error?: string) => void;
interface MachineConnection {
machineId: string;
label: string;
url: string;
token: string;
status: ConnectionStatus;
latencyMs: number | null;
ws: WebSocket | null;
heartbeatTimer: ReturnType<typeof setInterval> | null;
reconnectTimer: ReturnType<typeof setTimeout> | null;
cancelled: boolean;
lastPingSent: number;
}
// ── Relay Client ───────────────────────────────────────────────────────────
export class RelayClient {
private machines = new Map<string, MachineConnection>();
private eventListeners: EventCallback[] = [];
private statusListeners: StatusCallback[] = [];
/** Register an event listener for relay events from any machine. */
onEvent(cb: EventCallback): void {
this.eventListeners.push(cb);
}
/** Register a listener for connection status changes. */
onStatus(cb: StatusCallback): void {
this.statusListeners.push(cb);
}
/** Connect to an agor-relay instance. Returns a machine ID. */
async connect(url: string, token: string, label?: string): Promise<string> {
const machineId = randomUUID();
const machine: MachineConnection = {
machineId,
label: label ?? url,
url,
token,
status: "connecting",
latencyMs: null,
ws: null,
heartbeatTimer: null,
reconnectTimer: null,
cancelled: false,
lastPingSent: 0,
};
this.machines.set(machineId, machine);
this.emitStatus(machineId, "connecting");
try {
await this.openWebSocket(machine);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
machine.status = "error";
this.emitStatus(machineId, "error", msg);
this.scheduleReconnect(machine);
}
return machineId;
}
/** Disconnect from a relay and stop reconnection attempts. */
disconnect(machineId: string): void {
const machine = this.machines.get(machineId);
if (!machine) return;
machine.cancelled = true;
this.cleanupConnection(machine);
machine.status = "disconnected";
this.emitStatus(machineId, "disconnected");
}
/** Remove a machine entirely from tracking. */
removeMachine(machineId: string): void {
this.disconnect(machineId);
this.machines.delete(machineId);
}
/** Send a command to a connected relay. */
sendCommand(machineId: string, type: string, payload: Record<string, unknown>): void {
const machine = this.machines.get(machineId);
if (!machine?.ws || machine.status !== "connected") {
throw new Error(`Machine ${machineId} not connected`);
}
const cmd: RelayCommand = {
id: randomUUID(),
type,
payload,
};
machine.ws.send(JSON.stringify(cmd));
}
/** Get the status of a specific machine. */
getStatus(machineId: string): { status: ConnectionStatus; latencyMs: number | null } | null {
const machine = this.machines.get(machineId);
if (!machine) return null;
return { status: machine.status, latencyMs: machine.latencyMs };
}
/** List all tracked machines. */
listMachines(): Array<{
machineId: string;
label: string;
url: string;
status: ConnectionStatus;
latencyMs: number | null;
}> {
return Array.from(this.machines.values()).map((m) => ({
machineId: m.machineId,
label: m.label,
url: m.url,
status: m.status,
latencyMs: m.latencyMs,
}));
}
// ── Internal ─────────────────────────────────────────────────────────────
private async openWebSocket(machine: MachineConnection): Promise<void> {
return new Promise<void>((resolve, reject) => {
const ws = new WebSocket(machine.url, {
headers: {
Authorization: `Bearer ${machine.token}`,
},
} as unknown as string[]);
const timeout = setTimeout(() => {
ws.close();
reject(new Error("Connection timeout (10s)"));
}, 10_000);
ws.addEventListener("open", () => {
clearTimeout(timeout);
machine.ws = ws;
machine.status = "connected";
machine.cancelled = false;
this.emitStatus(machine.machineId, "connected");
this.startHeartbeat(machine);
resolve();
});
ws.addEventListener("message", (ev) => {
this.handleMessage(machine, String(ev.data));
});
ws.addEventListener("close", () => {
clearTimeout(timeout);
if (machine.status === "connected") {
this.cleanupConnection(machine);
machine.status = "disconnected";
this.emitStatus(machine.machineId, "disconnected");
if (!machine.cancelled) {
this.scheduleReconnect(machine);
}
}
});
ws.addEventListener("error", (ev) => {
clearTimeout(timeout);
const errMsg = "WebSocket error";
if (machine.status !== "connected") {
reject(new Error(errMsg));
} else {
this.cleanupConnection(machine);
machine.status = "error";
this.emitStatus(machine.machineId, "error", errMsg);
if (!machine.cancelled) {
this.scheduleReconnect(machine);
}
}
});
});
}
private handleMessage(machine: MachineConnection, data: string): void {
let event: RelayEvent;
try {
event = JSON.parse(data) as RelayEvent;
} catch {
console.error(`[relay] Invalid JSON from ${machine.machineId}`);
return;
}
// Handle pong for latency measurement
if (event.type === "pong") {
if (machine.lastPingSent > 0) {
machine.latencyMs = Date.now() - machine.lastPingSent;
}
return;
}
// Forward all other events
event.machineId = machine.machineId;
for (const cb of this.eventListeners) {
try {
cb(machine.machineId, event);
} catch (err) {
console.error("[relay] Event listener error:", err);
}
}
}
private startHeartbeat(machine: MachineConnection): void {
this.stopHeartbeat(machine);
machine.heartbeatTimer = setInterval(() => {
if (machine.ws?.readyState === WebSocket.OPEN) {
machine.lastPingSent = Date.now();
machine.ws.send(JSON.stringify({ id: "", type: "ping", payload: {} }));
}
}, 15_000);
}
private stopHeartbeat(machine: MachineConnection): void {
if (machine.heartbeatTimer) {
clearInterval(machine.heartbeatTimer);
machine.heartbeatTimer = null;
}
}
private cleanupConnection(machine: MachineConnection): void {
this.stopHeartbeat(machine);
if (machine.reconnectTimer) {
clearTimeout(machine.reconnectTimer);
machine.reconnectTimer = null;
}
if (machine.ws) {
try { machine.ws.close(); } catch { /* ignore */ }
machine.ws = null;
}
}
private scheduleReconnect(machine: MachineConnection): void {
let delay = 1_000;
const maxDelay = 30_000;
const attempt = async () => {
if (machine.cancelled || !this.machines.has(machine.machineId)) return;
machine.status = "connecting";
this.emitStatus(machine.machineId, "connecting");
// TCP probe first — avoids full WS overhead if host unreachable
const probeOk = await this.tcpProbe(machine.url);
if (!probeOk) {
delay = Math.min(delay * 2, maxDelay);
if (!machine.cancelled) {
machine.reconnectTimer = setTimeout(attempt, delay);
}
return;
}
try {
await this.openWebSocket(machine);
// Success — reset
} catch {
delay = Math.min(delay * 2, maxDelay);
if (!machine.cancelled) {
machine.reconnectTimer = setTimeout(attempt, delay);
}
}
};
machine.reconnectTimer = setTimeout(attempt, delay);
}
/** TCP-only probe to check if the relay host is reachable. */
private tcpProbe(url: string): Promise<boolean> {
return new Promise((resolve) => {
const host = this.extractHost(url);
if (!host) { resolve(false); return; }
const [hostname, portStr] = host.includes(":")
? [host.split(":")[0], host.split(":")[1]]
: [host, "9750"];
const port = parseInt(portStr, 10);
const socket = new Socket();
const timer = setTimeout(() => { socket.destroy(); resolve(false); }, 5_000);
socket.connect(port, hostname, () => {
clearTimeout(timer);
socket.destroy();
resolve(true);
});
socket.on("error", () => {
clearTimeout(timer);
socket.destroy();
resolve(false);
});
});
}
private extractHost(url: string): string | null {
return url.replace("wss://", "").replace("ws://", "").split("/")[0] ?? null;
}
private emitStatus(machineId: string, status: ConnectionStatus, error?: string): void {
for (const cb of this.statusListeners) {
try {
cb(machineId, status, error);
} catch (err) {
console.error("[relay] Status listener error:", err);
}
}
}
}

View file

@ -0,0 +1,193 @@
/**
* OpenTelemetry integration for the Bun process.
*
* Controlled by AGOR_OTLP_ENDPOINT env var:
* - Set (e.g. "http://localhost:4318") -> OTLP/HTTP trace export + console
* - Absent -> console-only (no network calls)
*
* Provides structured span creation for agent sessions, PTY operations, and
* RPC calls. Frontend events are forwarded via the telemetry.log RPC.
*/
// ── Types ──────────────────────────────────────────────────────────────────
export type LogLevel = "info" | "warn" | "error";
export interface SpanAttributes {
[key: string]: string | number | boolean;
}
interface ActiveSpan {
name: string;
attributes: SpanAttributes;
startTime: number;
}
// ── Telemetry Manager ──────────────────────────────────────────────────────
class TelemetryManager {
private enabled = false;
private endpoint = "";
private activeSpans = new Map<string, ActiveSpan>();
private spanCounter = 0;
private serviceName = "agent-orchestrator-electrobun";
private serviceVersion = "3.0.0-dev";
/** Initialize telemetry. Call once at startup. */
init(): void {
const endpoint = process.env.AGOR_OTLP_ENDPOINT ?? "";
const isTest = process.env.AGOR_TEST === "1";
if (endpoint && !isTest) {
this.enabled = true;
this.endpoint = endpoint.endsWith("/")
? endpoint + "v1/traces"
: endpoint + "/v1/traces";
console.log(`[telemetry] OTLP export enabled -> ${this.endpoint}`);
} else {
console.log("[telemetry] Console-only (AGOR_OTLP_ENDPOINT not set)");
}
}
/** Start a named span. Returns a spanId to pass to endSpan(). */
span(name: string, attributes: SpanAttributes = {}): string {
const spanId = `span_${++this.spanCounter}_${Date.now()}`;
this.activeSpans.set(spanId, {
name,
attributes,
startTime: Date.now(),
});
this.consoleLog("info", `[span:start] ${name}`, attributes);
return spanId;
}
/** End a span and optionally export it via OTLP. */
endSpan(spanId: string, extraAttributes: SpanAttributes = {}): void {
const active = this.activeSpans.get(spanId);
if (!active) return;
this.activeSpans.delete(spanId);
const durationMs = Date.now() - active.startTime;
const allAttributes = { ...active.attributes, ...extraAttributes, durationMs };
this.consoleLog("info", `[span:end] ${active.name} (${durationMs}ms)`, allAttributes);
if (this.enabled) {
this.exportSpan(active.name, active.startTime, durationMs, allAttributes);
}
}
/** Log a structured message. Used for frontend-forwarded events. */
log(level: LogLevel, message: string, attributes: SpanAttributes = {}): void {
this.consoleLog(level, message, attributes);
if (this.enabled) {
this.exportLog(level, message, attributes);
}
}
/** Shutdown — flush any pending exports. */
shutdown(): void {
this.activeSpans.clear();
if (this.enabled) {
console.log("[telemetry] Shutdown");
}
}
// ── Internal ─────────────────────────────────────────────────────────────
private consoleLog(level: LogLevel, message: string, attrs: SpanAttributes): void {
const attrStr = Object.keys(attrs).length > 0
? ` ${JSON.stringify(attrs)}`
: "";
switch (level) {
case "error": console.error(`[tel] ${message}${attrStr}`); break;
case "warn": console.warn(`[tel] ${message}${attrStr}`); break;
default: console.log(`[tel] ${message}${attrStr}`); break;
}
}
private async exportSpan(
name: string,
startTimeMs: number,
durationMs: number,
attributes: SpanAttributes,
): Promise<void> {
const traceId = this.randomHex(32);
const spanId = this.randomHex(16);
const startNs = BigInt(startTimeMs) * 1_000_000n;
const endNs = BigInt(startTimeMs + durationMs) * 1_000_000n;
const otlpPayload = {
resourceSpans: [{
resource: {
attributes: [
{ key: "service.name", value: { stringValue: this.serviceName } },
{ key: "service.version", value: { stringValue: this.serviceVersion } },
],
},
scopeSpans: [{
scope: { name: this.serviceName },
spans: [{
traceId,
spanId,
name,
kind: 1, // INTERNAL
startTimeUnixNano: startNs.toString(),
endTimeUnixNano: endNs.toString(),
attributes: Object.entries(attributes).map(([key, value]) => ({
key,
value: typeof value === "number"
? { intValue: value }
: typeof value === "boolean"
? { boolValue: value }
: { stringValue: String(value) },
})),
status: { code: 1 }, // OK
}],
}],
}],
};
try {
await fetch(this.endpoint, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(otlpPayload),
signal: AbortSignal.timeout(5_000),
});
} catch (err) {
console.warn("[telemetry] OTLP export failed:", err instanceof Error ? err.message : err);
}
}
private async exportLog(
level: LogLevel,
message: string,
attributes: SpanAttributes,
): Promise<void> {
// Wrap log as a zero-duration span for Tempo compatibility
await this.exportSpan(
`log.${level}`,
Date.now(),
0,
{ ...attributes, "log.message": message, "log.level": level },
);
}
private randomHex(length: number): string {
const bytes = new Uint8Array(length / 2);
crypto.getRandomValues(bytes);
return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
}
}
// ── Singleton ──────────────────────────────────────────────────────────────
export const telemetry = new TelemetryManager();
/** Initialize telemetry. Call once at app startup. */
export function initTelemetry(): void {
telemetry.init();
}

View file

@ -0,0 +1,117 @@
/**
* Auto-updater: checks GitHub Releases API for newer versions.
*
* Electrobun doesn't have a built-in updater mechanism yet, so this module
* only detects available updates and returns metadata no download/install.
*/
import { settingsDb } from "./settings-db.ts";
// ── Config ──────────────────────────────────────────────────────────────────
const GITHUB_OWNER = "DexterFromLab";
const GITHUB_REPO = "agents-orchestrator";
const RELEASES_URL = `https://api.github.com/repos/${GITHUB_OWNER}/${GITHUB_REPO}/releases/latest`;
/** Minimum interval between automatic checks (1 hour). */
const MIN_CHECK_INTERVAL_MS = 60 * 60 * 1000;
const SETTINGS_KEY_LAST_CHECK = "updater_last_check";
const SETTINGS_KEY_LAST_VERSION = "updater_last_version";
// ── Types ───────────────────────────────────────────────────────────────────
export interface UpdateCheckResult {
available: boolean;
version: string;
downloadUrl: string;
releaseNotes: string;
checkedAt: number;
}
interface GitHubRelease {
tag_name: string;
html_url: string;
body: string | null;
assets: Array<{
name: string;
browser_download_url: string;
}>;
}
// ── Semver comparison ───────────────────────────────────────────────────────
function parseSemver(v: string): [number, number, number] {
const clean = v.replace(/^v/, "");
const parts = clean.split("-")[0].split(".");
return [
parseInt(parts[0] ?? "0", 10),
parseInt(parts[1] ?? "0", 10),
parseInt(parts[2] ?? "0", 10),
];
}
function isNewer(remote: string, local: string): boolean {
const [rMaj, rMin, rPatch] = parseSemver(remote);
const [lMaj, lMin, lPatch] = parseSemver(local);
if (rMaj !== lMaj) return rMaj > lMaj;
if (rMin !== lMin) return rMin > lMin;
return rPatch > lPatch;
}
// ── Core ────────────────────────────────────────────────────────────────────
/**
* Check GitHub Releases API for a newer version.
* Returns update metadata (never downloads or installs anything).
*/
export async function checkForUpdates(
currentVersion: string,
): Promise<UpdateCheckResult> {
const now = Date.now();
const resp = await fetch(RELEASES_URL, {
headers: {
Accept: "application/vnd.github.v3+json",
"User-Agent": "AgentOrchestrator-Updater",
},
signal: AbortSignal.timeout(10_000),
});
if (!resp.ok) {
throw new Error(`GitHub API returned ${resp.status}: ${resp.statusText}`);
}
const release: GitHubRelease = await resp.json();
const remoteVersion = release.tag_name.replace(/^v/, "");
// Find a .deb or .AppImage asset as download URL, fallback to release page
const linuxAsset = release.assets.find(
(a) => a.name.endsWith(".deb") || a.name.endsWith(".AppImage"),
);
const downloadUrl = linuxAsset?.browser_download_url ?? release.html_url;
// Persist check timestamp
settingsDb.setSetting(SETTINGS_KEY_LAST_CHECK, String(now));
settingsDb.setSetting(SETTINGS_KEY_LAST_VERSION, remoteVersion);
return {
available: isNewer(remoteVersion, currentVersion),
version: remoteVersion,
downloadUrl,
releaseNotes: release.body ?? "",
checkedAt: now,
};
}
/** Return the timestamp of the last update check (0 if never). */
export function getLastCheckTimestamp(): number {
const val = settingsDb.getSetting(SETTINGS_KEY_LAST_CHECK);
return val ? parseInt(val, 10) || 0 : 0;
}
/** Return the last known remote version (empty string if never checked). */
export function getLastKnownVersion(): string {
return settingsDb.getSetting(SETTINGS_KEY_LAST_VERSION) ?? "";
}

View file

@ -1,12 +1,13 @@
<script lang="ts">
import AppearanceSettings from './settings/AppearanceSettings.svelte';
import AgentSettings from './settings/AgentSettings.svelte';
import SecuritySettings from './settings/SecuritySettings.svelte';
import ProjectSettings from './settings/ProjectSettings.svelte';
import OrchestrationSettings from './settings/OrchestrationSettings.svelte';
import AdvancedSettings from './settings/AdvancedSettings.svelte';
import MarketplaceTab from './settings/MarketplaceTab.svelte';
import KeyboardSettings from './settings/KeyboardSettings.svelte';
import AppearanceSettings from './settings/AppearanceSettings.svelte';
import AgentSettings from './settings/AgentSettings.svelte';
import SecuritySettings from './settings/SecuritySettings.svelte';
import ProjectSettings from './settings/ProjectSettings.svelte';
import OrchestrationSettings from './settings/OrchestrationSettings.svelte';
import AdvancedSettings from './settings/AdvancedSettings.svelte';
import MarketplaceTab from './settings/MarketplaceTab.svelte';
import KeyboardSettings from './settings/KeyboardSettings.svelte';
import RemoteMachinesSettings from './settings/RemoteMachinesSettings.svelte';
interface Props {
open: boolean;
@ -15,7 +16,7 @@
let { open, onClose }: Props = $props();
type CategoryId = 'appearance' | 'agents' | 'security' | 'projects' | 'orchestration' | 'advanced' | 'marketplace' | 'keyboard';
type CategoryId = 'appearance' | 'agents' | 'security' | 'projects' | 'orchestration' | 'machines' | 'advanced' | 'marketplace' | 'keyboard';
interface Category {
id: CategoryId;
@ -29,6 +30,7 @@
{ id: 'security', label: 'Security', icon: '🔒' },
{ id: 'projects', label: 'Projects', icon: '📁' },
{ id: 'orchestration', label: 'Orchestration', icon: '⚙' },
{ id: 'machines', label: 'Machines', icon: '🖥' },
{ id: 'keyboard', label: 'Keyboard', icon: '⌨' },
{ id: 'advanced', label: 'Advanced', icon: '🔧' },
{ id: 'marketplace', label: 'Marketplace', icon: '🛒' },
@ -94,6 +96,8 @@
<ProjectSettings />
{:else if activeCategory === 'orchestration'}
<OrchestrationSettings />
{:else if activeCategory === 'machines'}
<RemoteMachinesSettings />
{:else if activeCategory === 'advanced'}
<AdvancedSettings />
{:else if activeCategory === 'keyboard'}

View file

@ -0,0 +1,139 @@
<script lang="ts">
/**
* Full-screen splash overlay shown on app startup.
* Auto-dismisses when the `ready` prop becomes true.
* Fade-out transition: 300ms opacity.
*/
interface Props {
/** Set to true when app initialization is complete. */
ready: boolean;
}
let { ready }: Props = $props();
let visible = $state(true);
let fading = $state(false);
// When ready flips to true, start fade-out then hide
$effect(() => {
if (ready && visible && !fading) {
fading = true;
setTimeout(() => {
visible = false;
}, 300);
}
});
</script>
<div
class="splash"
style:display={visible ? 'flex' : 'none'}
class:fading
role="status"
aria-label="Loading application"
>
<div class="splash-content">
<div class="logo-text" aria-hidden="true">AGOR</div>
<div class="version">v0.0.1</div>
<div class="loading-indicator">
<span class="dot"></span>
<span class="dot"></span>
<span class="dot"></span>
</div>
<div class="loading-label">Loading...</div>
</div>
</div>
<style>
.splash {
position: fixed;
inset: 0;
z-index: 10000;
background: var(--ctp-base);
display: flex;
align-items: center;
justify-content: center;
opacity: 1;
transition: opacity 300ms ease-out;
}
.splash.fading {
opacity: 0;
pointer-events: none;
}
.splash-content {
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
}
.logo-text {
font-family: 'Inter', system-ui, sans-serif;
font-weight: 900;
font-size: 4rem;
letter-spacing: 0.3em;
background: linear-gradient(
135deg,
var(--ctp-mauve),
var(--ctp-blue),
var(--ctp-sapphire),
var(--ctp-teal)
);
background-size: 300% 300%;
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
animation: gradient-shift 3s ease infinite;
user-select: none;
}
@keyframes gradient-shift {
0% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
100% { background-position: 0% 50%; }
}
.version {
font-size: 0.875rem;
color: var(--ctp-overlay0);
font-family: var(--term-font-family, monospace);
letter-spacing: 0.05em;
}
.loading-indicator {
display: flex;
gap: 0.375rem;
margin-top: 0.5rem;
}
.dot {
width: 0.375rem;
height: 0.375rem;
border-radius: 50%;
background: var(--ctp-overlay1);
animation: pulse-dot 1.2s ease-in-out infinite;
}
.dot:nth-child(2) { animation-delay: 0.2s; }
.dot:nth-child(3) { animation-delay: 0.4s; }
@keyframes pulse-dot {
0%, 80%, 100% { opacity: 0.3; transform: scale(0.8); }
40% { opacity: 1; transform: scale(1); }
}
.loading-label {
font-size: 0.75rem;
color: var(--ctp-subtext0);
font-family: var(--ui-font-family, system-ui, sans-serif);
animation: pulse-text 2s ease-in-out infinite;
}
@keyframes pulse-text {
0%, 100% { opacity: 0.5; }
50% { opacity: 1; }
}
</style>

View file

@ -0,0 +1,89 @@
/**
* Svelte 5 rune store for remote machine state.
*
* Tracks connected machines, their status, and latency.
* Driven by remote.statusChange and remote.event messages from the Bun process.
*/
// ── Types ──────────────────────────────────────────────────────────────────
export type MachineStatus = "connecting" | "connected" | "disconnected" | "error";
export interface RemoteMachine {
machineId: string;
label: string;
url: string;
status: MachineStatus;
latencyMs: number | null;
error?: string;
}
// ── Store ──────────────────────────────────────────────────────────────────
let machines = $state<RemoteMachine[]>([]);
/** Add a machine to the tracked list. */
export function addMachine(
machineId: string,
url: string,
label?: string,
): void {
// Prevent duplicates
if (machines.some((m) => m.machineId === machineId)) return;
machines = [
...machines,
{
machineId,
label: label ?? url,
url,
status: "connecting",
latencyMs: null,
},
];
}
/** Remove a machine from tracking. */
export function removeMachine(machineId: string): void {
machines = machines.filter((m) => m.machineId !== machineId);
}
/** Update the status of a tracked machine. */
export function updateMachineStatus(
machineId: string,
status: MachineStatus,
error?: string,
): void {
machines = machines.map((m) =>
m.machineId === machineId
? { ...m, status, error: error ?? undefined }
: m,
);
}
/** Update the measured latency for a machine. */
export function updateMachineLatency(
machineId: string,
latencyMs: number,
): void {
machines = machines.map((m) =>
m.machineId === machineId ? { ...m, latencyMs } : m,
);
}
/** Get all tracked machines (reactive). */
export function getMachines(): RemoteMachine[] {
return machines;
}
/** Get a single machine by ID (reactive). */
export function getMachineStatus(
machineId: string,
): RemoteMachine | undefined {
return machines.find((m) => m.machineId === machineId);
}
/** Get count of connected machines (reactive). */
export function getConnectedCount(): number {
return machines.filter((m) => m.status === "connected").length;
}

View file

@ -18,9 +18,10 @@
let relayUrls = $state('');
let connTimeout = $state(30);
let appVersion = $state('3.0.0-dev');
let appVersion = $state('...');
let updateChecking = $state(false);
let updateResult = $state<string | null>(null);
let updateUrl = $state<string | null>(null);
let importError = $state<string | null>(null);
function persist(key: string, value: string) {
@ -39,13 +40,26 @@
persist('plugin_states', JSON.stringify(states));
}
function checkForUpdates() {
async function checkForUpdates() {
if (!appRpc) return;
updateChecking = true;
updateResult = null;
setTimeout(() => {
updateUrl = null;
try {
const result = await appRpc.request['updater.check']({});
if (result.error) {
updateResult = `Check failed: ${result.error}`;
} else if (result.available) {
updateResult = `Update available: v${result.version}`;
updateUrl = result.downloadUrl;
} else {
updateResult = `Already up to date (v${appVersion})`;
}
} catch (err) {
updateResult = `Check failed: ${err instanceof Error ? err.message : 'unknown error'}`;
} finally {
updateChecking = false;
updateResult = 'Already up to date (v3.0.0-dev)';
}, 1200);
}
}
async function handleExport() {
@ -99,6 +113,12 @@
plugins = plugins.map(p => ({ ...p, enabled: states[p.id] ?? p.enabled }));
} catch { /* ignore */ }
}
// Fetch real version from backend
try {
const { version } = await appRpc.request['updater.getVersion']({});
appVersion = version;
} catch { /* keep default */ }
}
onMount(loadSettings);
@ -159,7 +179,10 @@
</button>
</div>
{#if updateResult}
<p class="update-result">{updateResult}</p>
<p class="update-result" class:has-update={updateUrl}>{updateResult}</p>
{/if}
{#if updateUrl}
<a class="download-link" href={updateUrl} target="_blank" rel="noopener noreferrer">Download update</a>
{/if}
<h3 class="sh" style="margin-top: 0.75rem;">Settings Data</h3>
@ -211,6 +234,9 @@
.update-row { display: flex; align-items: center; gap: 0.625rem; }
.version-label { font-size: 0.75rem; color: var(--ctp-overlay1); font-family: var(--term-font-family, monospace); }
.update-result { font-size: 0.75rem; color: var(--ctp-green); margin: 0.125rem 0 0; }
.update-result.has-update { color: var(--ctp-peach); }
.download-link { font-size: 0.75rem; color: var(--ctp-blue); text-decoration: none; margin-top: 0.125rem; }
.download-link:hover { text-decoration: underline; color: var(--ctp-sapphire); }
.import-error { font-size: 0.75rem; color: var(--ctp-red); margin: 0.125rem 0 0; }
.data-row { display: flex; gap: 0.5rem; }

View file

@ -0,0 +1,269 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { appRpc } from '../rpc.ts';
import {
getMachines, addMachine, removeMachine, updateMachineStatus,
type RemoteMachine, type MachineStatus,
} from '../machines-store.svelte.ts';
// ── Form state ───────────────────────────────────────────────────────────
let newUrl = $state('');
let newToken = $state('');
let newLabel = $state('');
let error = $state<string | null>(null);
let loading = $state(false);
// ── Derived machine list ────────────────────────────────────────────────
let machines = $derived(getMachines());
// ── Poll for machine list from Bun ──────────────────────────────────────
let pollTimer: ReturnType<typeof setInterval> | null = null;
async function refreshMachines() {
if (!appRpc) return;
try {
const { machines: list } = await appRpc.request['remote.list']({});
for (const m of list) {
const existing = getMachines().find(x => x.machineId === m.machineId);
if (!existing) {
addMachine(m.machineId, m.url, m.label);
}
updateMachineStatus(m.machineId, m.status as MachineStatus);
}
} catch { /* best-effort */ }
}
onMount(() => {
refreshMachines();
pollTimer = setInterval(refreshMachines, 5_000);
});
onDestroy(() => {
if (pollTimer) clearInterval(pollTimer);
});
// ── Actions ──────────────────────────────────────────────────────────────
async function handleConnect() {
if (!newUrl.trim() || !newToken.trim()) {
error = 'URL and token are required.';
return;
}
error = null;
loading = true;
try {
const res = await appRpc.request['remote.connect']({
url: newUrl.trim(),
token: newToken.trim(),
label: newLabel.trim() || undefined,
});
if (res.ok && res.machineId) {
addMachine(res.machineId, newUrl.trim(), newLabel.trim() || undefined);
newUrl = '';
newToken = '';
newLabel = '';
} else {
error = res.error ?? 'Connection failed';
}
} catch (err) {
error = err instanceof Error ? err.message : 'Connection failed';
} finally {
loading = false;
}
}
async function handleDisconnect(machineId: string) {
try {
await appRpc.request['remote.disconnect']({ machineId });
updateMachineStatus(machineId, 'disconnected');
} catch { /* ignore */ }
}
async function handleRemove(machineId: string) {
try {
await appRpc.request['remote.disconnect']({ machineId });
} catch { /* may already be disconnected */ }
removeMachine(machineId);
}
function statusColor(status: MachineStatus): string {
switch (status) {
case 'connected': return 'var(--ctp-green)';
case 'connecting': return 'var(--ctp-yellow)';
case 'error': return 'var(--ctp-red)';
case 'disconnected': return 'var(--ctp-overlay0)';
}
}
function statusLabel(status: MachineStatus): string {
return status.charAt(0).toUpperCase() + status.slice(1);
}
function formatLatency(ms: number | null): string {
if (ms === null) return '--';
return `${ms}ms`;
}
</script>
<div class="section">
<h3 class="sh">Connected Machines</h3>
{#if machines.length === 0}
<p class="empty">No remote machines configured.</p>
{:else}
<div class="machine-list">
{#each machines as m (m.machineId)}
<div class="machine-row">
<div class="machine-info">
<span class="status-dot" style:background={statusColor(m.status)}></span>
<div class="machine-detail">
<span class="machine-label">{m.label}</span>
<span class="machine-url">{m.url}</span>
</div>
</div>
<div class="machine-meta">
<span class="latency">{formatLatency(m.latencyMs)}</span>
<span class="status-text" style:color={statusColor(m.status)}>
{statusLabel(m.status)}
</span>
</div>
<div class="machine-actions">
{#if m.status === 'connected'}
<button class="action-btn danger" onclick={() => handleDisconnect(m.machineId)}>
Disconnect
</button>
{:else if m.status === 'disconnected' || m.status === 'error'}
<button class="action-btn" onclick={() => {
appRpc.request['remote.connect']({
url: m.url, token: '', label: m.label,
}).catch(() => {});
}}>Reconnect</button>
{/if}
<button class="action-btn secondary" onclick={() => handleRemove(m.machineId)}
title="Remove machine">
x
</button>
</div>
</div>
{/each}
</div>
{/if}
<h3 class="sh" style="margin-top: 1rem;">Add Machine</h3>
<div class="field">
<label class="lbl" for="rm-label">Label (optional)</label>
<input id="rm-label" class="text-in" bind:value={newLabel}
placeholder="e.g. build-server" />
</div>
<div class="field">
<label class="lbl" for="rm-url">Relay URL</label>
<input id="rm-url" class="text-in" bind:value={newUrl}
placeholder="wss://relay.example.com:9750" />
</div>
<div class="field">
<label class="lbl" for="rm-token">Auth token</label>
<input id="rm-token" class="text-in" type="password" bind:value={newToken}
placeholder="Bearer token" />
</div>
{#if error}
<p class="error-msg">{error}</p>
{/if}
<button class="connect-btn" onclick={handleConnect} disabled={loading}>
{loading ? 'Connecting...' : 'Connect'}
</button>
</div>
<style>
.section { display: flex; flex-direction: column; gap: 0.5rem; }
.sh { margin: 0.125rem 0; font-size: 0.6875rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; color: var(--ctp-overlay0); }
.lbl { font-size: 0.75rem; color: var(--ctp-subtext0); }
.field { display: flex; flex-direction: column; gap: 0.2rem; }
.empty { font-size: 0.8rem; color: var(--ctp-overlay0); margin: 0; font-style: italic; }
.text-in {
padding: 0.3rem 0.5rem; background: var(--ctp-surface0);
border: 1px solid var(--ctp-surface1); border-radius: 0.25rem;
color: var(--ctp-text); font-size: 0.8125rem;
font-family: var(--ui-font-family);
}
.text-in:focus { outline: none; border-color: var(--ctp-blue); }
.machine-list { display: flex; flex-direction: column; gap: 0.25rem; }
.machine-row {
display: flex; align-items: center; gap: 0.5rem;
padding: 0.375rem 0.5rem; background: var(--ctp-surface0);
border: 1px solid var(--ctp-surface1); border-radius: 0.25rem;
}
.machine-info { display: flex; align-items: center; gap: 0.5rem; flex: 1; min-width: 0; }
.status-dot {
width: 8px; height: 8px; border-radius: 50%;
flex-shrink: 0;
}
.machine-detail {
display: flex; flex-direction: column; min-width: 0;
}
.machine-label {
font-size: 0.8rem; color: var(--ctp-text); font-weight: 500;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.machine-url {
font-size: 0.6875rem; color: var(--ctp-overlay0);
font-family: var(--term-font-family, monospace);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.machine-meta {
display: flex; flex-direction: column; align-items: flex-end;
gap: 0.125rem; flex-shrink: 0;
}
.latency {
font-size: 0.6875rem; color: var(--ctp-overlay1);
font-family: var(--term-font-family, monospace);
}
.status-text { font-size: 0.6875rem; font-weight: 500; }
.machine-actions { display: flex; gap: 0.25rem; flex-shrink: 0; }
.action-btn {
padding: 0.2rem 0.5rem; background: var(--ctp-surface0);
border: 1px solid var(--ctp-surface1); border-radius: 0.25rem;
color: var(--ctp-subtext1); font-size: 0.7rem; cursor: pointer;
font-family: var(--ui-font-family);
transition: background 0.12s, color 0.12s;
}
.action-btn:hover { background: var(--ctp-surface1); color: var(--ctp-text); }
.action-btn.secondary { color: var(--ctp-overlay0); }
.action-btn.danger { color: var(--ctp-red); border-color: color-mix(in srgb, var(--ctp-red) 30%, var(--ctp-surface1)); }
.action-btn.danger:hover { background: color-mix(in srgb, var(--ctp-red) 15%, var(--ctp-surface0)); }
.error-msg { font-size: 0.75rem; color: var(--ctp-red); margin: 0; }
.connect-btn {
align-self: flex-start;
padding: 0.3rem 1rem; background: var(--ctp-blue);
border: none; border-radius: 0.25rem;
color: var(--ctp-base); font-size: 0.8rem; font-weight: 600;
cursor: pointer; font-family: var(--ui-font-family);
transition: opacity 0.12s;
}
.connect-btn:hover:not(:disabled) { opacity: 0.85; }
.connect-btn:disabled { opacity: 0.4; cursor: not-allowed; }
</style>

View file

@ -0,0 +1,45 @@
/**
* Frontend telemetry bridge.
*
* Provides tel.info(), tel.warn(), tel.error() convenience methods that
* forward structured log events to the Bun process via RPC for tracing.
* No browser OTEL SDK needed (WebKitGTK incompatible).
*/
import { appRpc } from "./rpc.ts";
type LogLevel = "info" | "warn" | "error";
type Attributes = Record<string, string | number | boolean>;
function sendLog(level: LogLevel, message: string, attributes?: Attributes): void {
try {
appRpc?.request["telemetry.log"]({
level,
message,
attributes: attributes ?? {},
}).catch((err: unknown) => {
// Best-effort — never block the caller
console.warn("[tel-bridge] RPC failed:", err);
});
} catch {
// RPC not yet initialized — swallow silently
}
}
/** Frontend telemetry API. All calls are fire-and-forget. */
export const tel = {
/** Log an informational event. */
info(message: string, attributes?: Attributes): void {
sendLog("info", message, attributes);
},
/** Log a warning event. */
warn(message: string, attributes?: Attributes): void {
sendLog("warn", message, attributes);
},
/** Log an error event. */
error(message: string, attributes?: Attributes): void {
sendLog("error", message, attributes);
},
} as const;

View file

@ -482,6 +482,78 @@ export type PtyRPCRequests = {
params: { pluginId: string; filePath: string };
response: { ok: boolean; content: string; error?: string };
};
// ── Remote machine (relay) RPC ────────────────────────────────────────────
/** Connect to an agor-relay instance. */
"remote.connect": {
params: { url: string; token: string; label?: string };
response: { ok: boolean; machineId?: string; error?: string };
};
/** Disconnect from a relay instance. */
"remote.disconnect": {
params: { machineId: string };
response: { ok: boolean; error?: string };
};
/** List all known remote machines with connection status. */
"remote.list": {
params: Record<string, never>;
response: {
machines: Array<{
machineId: string;
label: string;
url: string;
status: "connecting" | "connected" | "disconnected" | "error";
latencyMs: number | null;
}>;
};
};
/** Send a command to a connected relay. */
"remote.send": {
params: { machineId: string; command: string; payload: Record<string, unknown> };
response: { ok: boolean; error?: string };
};
/** Get the status of a specific machine. */
"remote.status": {
params: { machineId: string };
response: {
status: "connecting" | "connected" | "disconnected" | "error";
latencyMs: number | null;
error?: string;
};
};
// ── Telemetry RPC ─────────────────────────────────────────────────────────
/** Log a telemetry event from the frontend. */
"telemetry.log": {
params: {
level: "info" | "warn" | "error";
message: string;
attributes?: Record<string, string | number | boolean>;
};
response: { ok: boolean };
};
// ── Updater RPC ──────────────────────────────────────────────────────────
/** Check GitHub Releases for a newer version. */
"updater.check": {
params: Record<string, never>;
response: {
available: boolean;
version: string;
downloadUrl: string;
releaseNotes: string;
checkedAt: number;
error?: string;
};
};
/** Get the current app version and last check timestamp. */
"updater.getVersion": {
params: Record<string, never>;
response: { version: string; lastCheck: number };
};
};
// ── Messages (Bun → WebView, fire-and-forget) ────────────────────────────────
@ -518,6 +590,22 @@ export type PtyRPCMessages = {
inputTokens: number;
outputTokens: number;
};
// ── Remote machine events (Bun → WebView) ────────────────────────────────
/** Remote relay event forwarded from a connected machine. */
"remote.event": {
machineId: string;
eventType: string;
sessionId?: string;
payload?: unknown;
};
/** Remote machine connection status change. */
"remote.statusChange": {
machineId: string;
status: "connecting" | "connected" | "disconnected" | "error";
error?: string;
};
};
// ── Combined schema ───────────────────────────────────────────────────────────