agent-orchestrator/agor-pty/clients/ts/pty-client.ts
Hibryda 4676fc2c94 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
2026-03-20 03:20:13 +01:00

248 lines
7.3 KiB
TypeScript

/**
* 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();
}
}