fix(electrobun): copy pty-client locally, fix import path for Bun bundler
This commit is contained in:
parent
4676fc2c94
commit
0f7024ec8f
7 changed files with 509 additions and 52 deletions
|
|
@ -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,
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
248
ui-electrobun/src/bun/pty-client.ts
Normal file
248
ui-electrobun/src/bun/pty-client.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue