- 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
248 lines
7.3 KiB
TypeScript
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();
|
|
}
|
|
}
|