/** * 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 } | { type: "session_output"; session_id: string; data: number[] } | { 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 { 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; 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. */ 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 }); } /** 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): 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)); * } */ export async function* ptySession( sessionId: string, opts?: { shell?: string; cwd?: string; cols?: number; rows?: number; socketDir?: string } ): AsyncGenerator { 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) { yield msg.data; } else if (msg.type === "session_closed" && msg.session_id === sessionId) { break; } } } finally { client.disconnect(); } }