From 4676fc2c94e2308e261202310eab70f6a22a3cb7 Mon Sep 17 00:00:00 2001 From: Hibryda Date: Fri, 20 Mar 2026 03:20:13 +0100 Subject: [PATCH] feat(electrobun): wire PTY daemon into terminal tabs via Electrobun RPC - Bun process connects to agor-ptyd via PtyClient (5 retries, exponential backoff) - RPC bridge: 5 request handlers (create/write/resize/unsubscribe/close) - Daemon output forwarded to WebView as pty.output messages (base64 passthrough) - Terminal.svelte: real PTY sessions via RPC instead of echo mode - Shared RPC schema at src/shared/pty-rpc-schema.ts - Fixed pty-client.ts protocol: base64 string for data (was number array) - TerminalTabs passes sessionId to Terminal component --- agor-pty/clients/ts/pty-client.ts | 29 ++- ui-electrobun/src/bun/index.ts | 178 +++++++++++++++--- ui-electrobun/src/mainview/Terminal.svelte | 105 ++++++----- .../src/mainview/TerminalTabs.svelte | 23 ++- ui-electrobun/src/mainview/main.ts | 32 +++- ui-electrobun/src/shared/pty-rpc-schema.ts | 62 ++++++ 6 files changed, 343 insertions(+), 86 deletions(-) create mode 100644 ui-electrobun/src/shared/pty-rpc-schema.ts diff --git a/agor-pty/clients/ts/pty-client.ts b/agor-pty/clients/ts/pty-client.ts index 411ba20..8837052 100644 --- a/agor-pty/clients/ts/pty-client.ts +++ b/agor-pty/clients/ts/pty-client.ts @@ -25,7 +25,8 @@ export interface SessionInfo { export type DaemonEvent = | { type: "auth_result"; ok: boolean } | { type: "session_created"; session_id: string; pid: number } - | { type: "session_output"; session_id: string; data: number[] } + /** data is base64-encoded bytes from the PTY. */ + | { type: "session_output"; session_id: string; data: string } | { type: "session_closed"; session_id: string; exit_code: number | null } | { type: "session_list"; sessions: SessionInfo[] } | { type: "pong" } @@ -131,13 +132,15 @@ export class PtyClient extends EventEmitter { }); } - /** Write input to a session's PTY. */ + /** Write input to a session's PTY. data is encoded as base64 for transport. */ writeInput(sessionId: string, data: string | Uint8Array): void { const bytes = typeof data === "string" - ? Array.from(new TextEncoder().encode(data)) - : Array.from(data); - this.send({ type: "write_input", session_id: sessionId, data: bytes }); + ? new TextEncoder().encode(data) + : data; + // Daemon expects base64 string per protocol.rs WriteInput { data: String } + const b64 = btoa(String.fromCharCode(...bytes)); + this.send({ type: "write_input", session_id: sessionId, data: b64 }); } /** Resize a session's terminal. */ @@ -199,10 +202,18 @@ export class PtyClient extends EventEmitter { * terminal.write(new Uint8Array(chunk)); * } */ +/** + * Connect to daemon, create a session, and return output as an async iterator. + * Each yielded chunk is a Uint8Array of raw PTY bytes (decoded from base64). + * Usage: + * for await (const chunk of ptySession("my-shell", { cols: 120, rows: 40 })) { + * terminal.write(chunk); + * } + */ export async function* ptySession( sessionId: string, opts?: { shell?: string; cwd?: string; cols?: number; rows?: number; socketDir?: string } -): AsyncGenerator { +): AsyncGenerator { const client = new PtyClient(opts?.socketDir); await client.connect(); @@ -222,7 +233,11 @@ export async function* ptySession( }); if (msg.type === "session_output" && msg.session_id === sessionId) { - yield msg.data; + // Decode base64 → raw bytes + const binary = atob(msg.data); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); + yield bytes; } else if (msg.type === "session_closed" && msg.session_id === sessionId) { break; } diff --git a/ui-electrobun/src/bun/index.ts b/ui-electrobun/src/bun/index.ts index 861cd38..2221f27 100644 --- a/ui-electrobun/src/bun/index.ts +++ b/ui-electrobun/src/bun/index.ts @@ -1,37 +1,165 @@ -import { BrowserWindow, Updater } from "electrobun/bun"; +import path from "path"; +import { BrowserWindow, BrowserView, Updater } from "electrobun/bun"; +import { PtyClient } from "../../agor-pty/clients/ts/pty-client.ts"; +import type { PtyRPCSchema } from "../shared/pty-rpc-schema.ts"; const DEV_SERVER_PORT = 9760; // Project convention: 9700+ range const DEV_SERVER_URL = `http://localhost:${DEV_SERVER_PORT}`; -// Check if Vite dev server is running for HMR -async function getMainViewUrl(): Promise { - const channel = await Updater.localInfo.channel(); - if (channel === "dev") { - try { - await fetch(DEV_SERVER_URL, { method: "HEAD" }); - console.log(`HMR enabled: Using Vite dev server at ${DEV_SERVER_URL}`); - return DEV_SERVER_URL; - } catch { - console.log( - "Vite dev server not running. Run 'bun run dev:hmr' for HMR support.", - ); - } - } - return "views://mainview/index.html"; +// ── PTY daemon client ──────────────────────────────────────────────────────── + +// Resolve daemon socket directory. agor-ptyd writes ptyd.sock and ptyd.token +// into $XDG_RUNTIME_DIR/agor or ~/.local/share/agor/run. +const ptyClient = new PtyClient(); + +async function connectToDaemon(retries = 5, delayMs = 500): Promise { + for (let attempt = 1; attempt <= retries; attempt++) { + try { + await ptyClient.connect(); + console.log("[agor-ptyd] Connected to PTY daemon"); + return true; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + if (attempt < retries) { + console.warn(`[agor-ptyd] Connect attempt ${attempt}/${retries} failed: ${msg}. Retrying in ${delayMs}ms…`); + await new Promise((r) => setTimeout(r, delayMs)); + delayMs = Math.min(delayMs * 2, 4000); + } else { + console.error(`[agor-ptyd] Could not connect after ${retries} attempts: ${msg}`); + console.error("[agor-ptyd] Terminals will not work. Start agor-ptyd and restart the app."); + } + } + } + return false; } -// Create the main application window +// ── RPC definition ──────────────────────────────────────────────────────────── + +/** + * BrowserView.defineRPC defines handlers that the WebView can call. + * The schema type parameter describes what the Bun side handles (requests from + * WebView) and what messages Bun sends to the WebView. + * + * Pattern: handlers.requests = WebView→Bun calls + * handlers.messages = Bun→WebView fire-and-forget + * To push a message to the WebView: rpc.send["pty.output"](payload) + */ +const rpc = BrowserView.defineRPC({ + maxRequestTime: 10_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 }); + ptyClient.subscribe(sessionId); + 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) return { ok: false }; + try { + ptyClient.writeInput(sessionId, data); + return { ok: true }; + } catch (err) { + console.error(`[pty.write] ${sessionId}:`, err); + return { ok: false }; + } + }, + + "pty.resize": ({ sessionId, cols, rows }) => { + if (!ptyClient.isConnected) return { ok: true }; // Best-effort + try { + ptyClient.resize(sessionId, cols, rows); + } catch { /* ignore */ } + return { ok: true }; + }, + + "pty.unsubscribe": ({ sessionId }) => { + try { + ptyClient.unsubscribe(sessionId); + } catch { /* ignore */ } + return { ok: true }; + }, + + "pty.close": ({ sessionId }) => { + try { + ptyClient.closeSession(sessionId); + } catch { /* ignore */ } + return { ok: true }; + }, + }, + + messages: { + // Messages section defines what the WebView can *send* to Bun (fire-and-forget). + // We don't expect any inbound messages from the WebView in this direction. + }, + }, +}); + +// ── Forward daemon events to WebView ──────────────────────────────────────── + +// session_output: forward each chunk to the WebView as a "pty.output" message. +ptyClient.on("session_output", (msg) => { + if (msg.type !== "session_output") return; + try { + // data is already base64 from the daemon — pass it straight through. + rpc.send["pty.output"]({ sessionId: msg.session_id, data: msg.data }); + } catch (err) { + console.error("[pty.output] forward error:", err); + } +}); + +// session_closed: notify the WebView so it can display "[Process exited]". +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); + } +}); + +// ── App window ─────────────────────────────────────────────────────────────── + +async function getMainViewUrl(): Promise { + const channel = await Updater.localInfo.channel(); + if (channel === "dev") { + try { + await fetch(DEV_SERVER_URL, { method: "HEAD" }); + console.log(`HMR enabled: Using Vite dev server at ${DEV_SERVER_URL}`); + return DEV_SERVER_URL; + } catch { + console.log( + "Vite dev server not running. Run 'bun run dev:hmr' for HMR support.", + ); + } + } + return "views://mainview/index.html"; +} + +// Connect to daemon (non-blocking — window opens regardless). +connectToDaemon(); + const url = await getMainViewUrl(); const mainWindow = new BrowserWindow({ - title: "Agent Orchestrator — Electrobun", - url, - frame: { - width: 1400, - height: 900, - x: 100, - y: 100, - }, + title: "Agent Orchestrator — Electrobun", + url, + rpc, + frame: { + width: 1400, + height: 900, + x: 100, + y: 100, + }, }); console.log("Agent Orchestrator (Electrobun) started!"); diff --git a/ui-electrobun/src/mainview/Terminal.svelte b/ui-electrobun/src/mainview/Terminal.svelte index fc50c31..ddfb678 100644 --- a/ui-electrobun/src/mainview/Terminal.svelte +++ b/ui-electrobun/src/mainview/Terminal.svelte @@ -4,6 +4,15 @@ import { CanvasAddon } from '@xterm/addon-canvas'; import { FitAddon } from '@xterm/addon-fit'; import { ImageAddon } from '@xterm/addon-image'; + import { electrobun } from './main.ts'; + + interface Props { + sessionId: string; + /** Working directory to open the shell in. */ + cwd?: string; + } + + let { sessionId, cwd }: Props = $props(); // Catppuccin Mocha terminal theme const THEME = { @@ -34,14 +43,15 @@ let term: Terminal; let fitAddon: FitAddon; - // Current line buffer for demo shell simulation - let lineBuffer = ''; - - function writePrompt() { - term.write('\r\n\x1b[1;34m~/code/ai/agent-orchestrator\x1b[0m \x1b[32m$\x1b[0m '); + /** Decode a base64 string from the daemon into a Uint8Array. */ + function decodeBase64(b64: string): Uint8Array { + const binary = atob(b64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); + return bytes; } - onMount(() => { + onMount(async () => { term = new Terminal({ theme: THEME, fontFamily: "'JetBrains Mono', 'Fira Code', monospace", @@ -55,7 +65,7 @@ term.loadAddon(fitAddon); term.loadAddon(new CanvasAddon()); - // Image addon — enables Sixel, iTerm2, and Kitty inline image protocols + // Sixel / iTerm2 / Kitty inline image support term.loadAddon(new ImageAddon({ enableSizeReports: true, sixelSupport: true, @@ -67,57 +77,56 @@ term.open(termEl); fitAddon.fit(); - // Demo content with ANSI colors - term.writeln('\x1b[1;34m~/code/ai/agent-orchestrator\x1b[0m \x1b[32m$\x1b[0m cargo test --workspace'); - term.writeln(' \x1b[1;32mCompiling\x1b[0m agor-core v0.1.0'); - term.writeln(' \x1b[1;32mCompiling\x1b[0m agor-gpui v0.1.0'); - term.writeln(' \x1b[1;32mRunning\x1b[0m tests/unit.rs'); - term.writeln('test result: ok. \x1b[32m47 passed\x1b[0m; 0 failed'); - term.writeln(''); - term.write('\x1b[1;34m~/code/ai/agent-orchestrator\x1b[0m \x1b[32m$\x1b[0m '); + // ── Connect to PTY daemon via Bun RPC ────────────────────────────────── - // Input handler — simulate local shell until real PTY is connected - term.onData((data: string) => { - for (const ch of data) { - const code = ch.charCodeAt(0); + const { cols, rows } = term; + const result = await electrobun.rpc?.request["pty.create"]({ sessionId, cols, rows, cwd }); + if (!result?.ok) { + term.writeln(`\x1b[31m[agor] Failed to connect to PTY daemon: ${result?.error ?? 'unknown error'}\x1b[0m`); + term.writeln('\x1b[33m[agor] Is agor-ptyd running? Start it with: agor-ptyd\x1b[0m'); + } - if (ch === '\r') { - // Enter key — echo newline, execute (demo), write new prompt - const cmd = lineBuffer.trim(); - lineBuffer = ''; - if (cmd === '') { - writePrompt(); - } else if (cmd === 'clear') { - term.clear(); - term.write('\x1b[1;34m~/code/ai/agent-orchestrator\x1b[0m \x1b[32m$\x1b[0m '); - } else { - term.write('\r\n'); - term.writeln(`\x1b[31mbash: ${cmd}: command not found\x1b[0m`); - writePrompt(); - } - } else if (code === 127 || ch === '\x08') { - // Backspace - if (lineBuffer.length > 0) { - lineBuffer = lineBuffer.slice(0, -1); - term.write('\b \b'); - } - } else if (code >= 32 && code !== 127) { - // Printable character — echo and buffer - lineBuffer += ch; - term.write(ch); - } - // Ignore control characters (Ctrl+C etc. — no real PTY) - } + // ── Receive output from daemon (via Bun) ─────────────────────────────── + + // "pty.output" messages are pushed by Bun whenever the PTY produces data. + electrobun.rpc?.addMessageListener("pty.output", ({ sessionId: sid, data }) => { + if (sid !== sessionId) return; + term.write(decodeBase64(data)); }); - // Resize on container resize - const ro = new ResizeObserver(() => fitAddon.fit()); + // "pty.closed" fires when the shell exits. + electrobun.rpc?.addMessageListener("pty.closed", ({ sessionId: sid, exitCode }) => { + if (sid !== sessionId) return; + term.writeln(`\r\n\x1b[90m[Process exited${exitCode !== null ? ` with code ${exitCode}` : ''}]\x1b[0m`); + }); + + // ── Send user input to daemon ────────────────────────────────────────── + + term.onData((data: string) => { + electrobun.rpc?.request["pty.write"]({ sessionId, data }).catch((err) => { + console.error('[pty.write] error:', err); + }); + }); + + // ── Sync resize events to daemon ─────────────────────────────────────── + + term.onResize(({ cols: c, rows: r }) => { + electrobun.rpc?.request["pty.resize"]({ sessionId, cols: c, rows: r }).catch(() => {}); + }); + + // Refit on container resize and notify the daemon. + const ro = new ResizeObserver(() => { + fitAddon.fit(); + }); ro.observe(termEl); return () => ro.disconnect(); }); onDestroy(() => { + // Unsubscribe from daemon output (session stays alive so it can be + // reconnected if the tab is re-opened, matching the "persists" requirement). + electrobun.rpc?.request["pty.unsubscribe"]({ sessionId }).catch(() => {}); term?.dispose(); }); diff --git a/ui-electrobun/src/mainview/TerminalTabs.svelte b/ui-electrobun/src/mainview/TerminalTabs.svelte index e9c3cfc..9288cea 100644 --- a/ui-electrobun/src/mainview/TerminalTabs.svelte +++ b/ui-electrobun/src/mainview/TerminalTabs.svelte @@ -69,7 +69,7 @@
-
+ {/each}
@@ -114,7 +121,7 @@ {#each tabs as tab (tab.id)} {#if mounted.has(tab.id)}
- +
{/if} {/each} @@ -198,6 +205,12 @@ display: flex; align-items: center; justify-content: center; + /* Reset