fix(electrobun): copy pty-client locally, fix import path for Bun bundler

This commit is contained in:
Hibryda 2026-03-20 03:23:52 +01:00
parent 4676fc2c94
commit 0f7024ec8f
7 changed files with 509 additions and 52 deletions

View file

@ -231745,9 +231745,217 @@ RegisterNativeTypeAsync("NativeXRFrame", NativeXRFrame);
init_BuildConfig();
await init_native();
// src/bun/pty-client.ts
import { connect } from "net";
import { readFileSync as readFileSync2 } from "fs";
import { join as join6 } from "path";
import { EventEmitter as EventEmitter2 } from "events";
class PtyClient extends EventEmitter2 {
socket = null;
buffer = "";
authenticated = false;
socketPath;
tokenPath;
constructor(socketDir) {
super();
const dir = socketDir ?? (process.env.XDG_RUNTIME_DIR ? join6(process.env.XDG_RUNTIME_DIR, "agor") : join6(process.env.HOME ?? "/tmp", ".local", "share", "agor", "run"));
this.socketPath = join6(dir, "ptyd.sock");
this.tokenPath = join6(dir, "ptyd.token");
}
async connect() {
return new Promise((resolve3, reject) => {
let token;
try {
token = readFileSync2(this.tokenPath, "utf-8").trim();
} catch {
reject(new Error(`Cannot read token at ${this.tokenPath}. Is agor-ptyd running?`));
return;
}
this.socket = connect(this.socketPath);
this.socket.on("connect", () => {
this.send({ type: "auth", token });
});
this.socket.on("data", (chunk) => {
this.buffer += chunk.toString("utf-8");
let newlineIdx;
while ((newlineIdx = this.buffer.indexOf(`
`)) !== -1) {
const line = this.buffer.slice(0, newlineIdx);
this.buffer = this.buffer.slice(newlineIdx + 1);
try {
const msg = JSON.parse(line);
if (!this.authenticated && msg.type === "auth_result") {
if (msg.ok) {
this.authenticated = true;
resolve3();
} else {
reject(new Error("Authentication failed"));
}
} else {
this.emit("message", msg);
this.emit(msg.type, msg);
}
} catch {}
}
});
this.socket.on("error", (err) => {
if (!this.authenticated)
reject(err);
this.emit("error", err);
});
this.socket.on("close", () => {
this.authenticated = false;
this.emit("close");
});
});
}
createSession(opts) {
this.send({
type: "create_session",
id: opts.id,
shell: opts.shell ?? null,
cwd: opts.cwd ?? null,
env: opts.env ?? null,
cols: opts.cols ?? 80,
rows: opts.rows ?? 24
});
}
writeInput(sessionId, data) {
const bytes = typeof data === "string" ? new TextEncoder().encode(data) : data;
const b64 = btoa(String.fromCharCode(...bytes));
this.send({ type: "write_input", session_id: sessionId, data: b64 });
}
resize(sessionId, cols, rows) {
this.send({ type: "resize", session_id: sessionId, cols, rows });
}
subscribe(sessionId) {
this.send({ type: "subscribe", session_id: sessionId });
}
unsubscribe(sessionId) {
this.send({ type: "unsubscribe", session_id: sessionId });
}
closeSession(sessionId) {
this.send({ type: "close_session", session_id: sessionId });
}
listSessions() {
this.send({ type: "list_sessions" });
}
ping() {
this.send({ type: "ping" });
}
disconnect() {
this.socket?.end();
this.socket = null;
this.authenticated = false;
}
get isConnected() {
return this.authenticated && this.socket !== null && !this.socket.destroyed;
}
send(msg) {
if (!this.socket || this.socket.destroyed) {
throw new Error("Not connected to daemon");
}
this.socket.write(JSON.stringify(msg) + `
`);
}
}
// src/bun/index.ts
var DEV_SERVER_PORT = 9760;
var DEV_SERVER_URL = `http://localhost:${DEV_SERVER_PORT}`;
var ptyClient = new PtyClient;
async function connectToDaemon(retries = 5, delayMs = 500) {
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\u2026`);
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;
}
var rpc = BrowserView.defineRPC({
maxRequestTime: 1e4,
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 error2 = err instanceof Error ? err.message : String(err);
console.error(`[pty.create] ${sessionId}: ${error2}`);
return { ok: false, error: error2 };
}
},
"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 };
try {
ptyClient.resize(sessionId, cols, rows);
} catch {}
return { ok: true };
},
"pty.unsubscribe": ({ sessionId }) => {
try {
ptyClient.unsubscribe(sessionId);
} catch {}
return { ok: true };
},
"pty.close": ({ sessionId }) => {
try {
ptyClient.closeSession(sessionId);
} catch {}
return { ok: true };
}
},
messages: {}
}
});
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);
}
});
async function getMainViewUrl() {
const channel = await Updater.localInfo.channel();
if (channel === "dev") {
@ -231761,10 +231969,12 @@ async function getMainViewUrl() {
}
return "views://mainview/index.html";
}
connectToDaemon();
var url = await getMainViewUrl();
var mainWindow = new BrowserWindow({
title: "Agent Orchestrator \u2014 Electrobun",
url,
rpc,
frame: {
width: 1400,
height: 900,

View file

@ -4,8 +4,8 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Svelte App</title>
<script type="module" crossorigin src="/assets/index-U683DxRe.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-RZPm-TN9.css">
<script type="module" crossorigin src="/assets/index-BSJ7bu8E.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BzuwgabH.css">
</head>
<body>
<div id="app"></div>

View file

@ -1,6 +1,6 @@
import path from "path";
import { BrowserWindow, BrowserView, Updater } from "electrobun/bun";
import { PtyClient } from "../../agor-pty/clients/ts/pty-client.ts";
import { PtyClient } from "./pty-client.ts";
import type { PtyRPCSchema } from "../shared/pty-rpc-schema.ts";
const DEV_SERVER_PORT = 9760; // Project convention: 9700+ range

View file

@ -0,0 +1,248 @@
/**
* agor-pty TypeScript IPC client.
* Connects to the agor-ptyd daemon via Unix socket.
* Works with both Bun (Electrobun) and Node.js (Tauri sidecar).
*/
import { connect, type Socket } from "net";
import { readFileSync } from "fs";
import { join } from "path";
import { EventEmitter } from "events";
// ── IPC Protocol Types ──────────────────────────────────────────
export interface SessionInfo {
id: string;
pid: number;
shell: string;
cwd: string;
cols: number;
rows: number;
created_at: number;
alive: boolean;
}
export type DaemonEvent =
| { type: "auth_result"; ok: boolean }
| { type: "session_created"; session_id: string; pid: 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" }
| { type: "error"; message: string };
// ── Client ──────────────────────────────────────────────────────
export class PtyClient extends EventEmitter {
private socket: Socket | null = null;
private buffer = "";
private authenticated = false;
private socketPath: string;
private tokenPath: string;
constructor(socketDir?: string) {
super();
const dir =
socketDir ??
(process.env.XDG_RUNTIME_DIR
? join(process.env.XDG_RUNTIME_DIR, "agor")
: join(
process.env.HOME ?? "/tmp",
".local",
"share",
"agor",
"run"
));
this.socketPath = join(dir, "ptyd.sock");
this.tokenPath = join(dir, "ptyd.token");
}
/** Connect to daemon and authenticate. */
async connect(): Promise<void> {
return new Promise((resolve, reject) => {
let token: string;
try {
token = readFileSync(this.tokenPath, "utf-8").trim();
} catch {
reject(new Error(`Cannot read token at ${this.tokenPath}. Is agor-ptyd running?`));
return;
}
this.socket = connect(this.socketPath);
this.socket.on("connect", () => {
this.send({ type: "auth", token });
});
this.socket.on("data", (chunk: Buffer) => {
this.buffer += chunk.toString("utf-8");
let newlineIdx: number;
while ((newlineIdx = this.buffer.indexOf("\n")) !== -1) {
const line = this.buffer.slice(0, newlineIdx);
this.buffer = this.buffer.slice(newlineIdx + 1);
try {
const msg = JSON.parse(line) as DaemonEvent;
if (!this.authenticated && msg.type === "auth_result") {
if (msg.ok) {
this.authenticated = true;
resolve();
} else {
reject(new Error("Authentication failed"));
}
} else {
this.emit("message", msg);
this.emit(msg.type, msg);
}
} catch {
// Ignore malformed lines
}
}
});
this.socket.on("error", (err) => {
if (!this.authenticated) reject(err);
this.emit("error", err);
});
this.socket.on("close", () => {
this.authenticated = false;
this.emit("close");
});
});
}
/** Create a new PTY session. */
createSession(opts: {
id: string;
shell?: string;
cwd?: string;
env?: Record<string, string>;
cols?: number;
rows?: number;
}): void {
this.send({
type: "create_session",
id: opts.id,
shell: opts.shell ?? null,
cwd: opts.cwd ?? null,
env: opts.env ?? null,
cols: opts.cols ?? 80,
rows: opts.rows ?? 24,
});
}
/** 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"
? 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. */
resize(sessionId: string, cols: number, rows: number): void {
this.send({ type: "resize", session_id: sessionId, cols, rows });
}
/** Subscribe to a session's output. */
subscribe(sessionId: string): void {
this.send({ type: "subscribe", session_id: sessionId });
}
/** Unsubscribe from a session's output. */
unsubscribe(sessionId: string): void {
this.send({ type: "unsubscribe", session_id: sessionId });
}
/** Close/kill a session. */
closeSession(sessionId: string): void {
this.send({ type: "close_session", session_id: sessionId });
}
/** List all active sessions. */
listSessions(): void {
this.send({ type: "list_sessions" });
}
/** Ping the daemon. */
ping(): void {
this.send({ type: "ping" });
}
/** Disconnect from daemon (sessions stay alive). */
disconnect(): void {
this.socket?.end();
this.socket = null;
this.authenticated = false;
}
/** Check if connected and authenticated. */
get isConnected(): boolean {
return this.authenticated && this.socket !== null && !this.socket.destroyed;
}
private send(msg: Record<string, unknown>): void {
if (!this.socket || this.socket.destroyed) {
throw new Error("Not connected to daemon");
}
this.socket.write(JSON.stringify(msg) + "\n");
}
}
// ── Convenience: auto-connect + session helpers ─────────────────
/**
* Connect to daemon, create a session, and return output as an async iterator.
* Usage:
* for await (const chunk of ptySession("my-shell", { cols: 120, rows: 40 })) {
* 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<Uint8Array, void, void> {
const client = new PtyClient(opts?.socketDir);
await client.connect();
client.createSession({
id: sessionId,
shell: opts?.shell,
cwd: opts?.cwd,
cols: opts?.cols ?? 80,
rows: opts?.rows ?? 24,
});
client.subscribe(sessionId);
try {
while (client.isConnected) {
const msg: DaemonEvent = await new Promise((resolve) => {
client.once("message", resolve);
});
if (msg.type === "session_output" && msg.session_id === sessionId) {
// 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;
}
}
} finally {
client.disconnect();
}
}