feat: agor-pty crate — standalone PTY multiplexer daemon (Phase 1 WIP)
New crate at agor-pty/ (standalone, not in workspace — portable to Tauri/Electrobun): - Rust daemon (agor-ptyd) with Unix socket IPC, JSON-framed protocol - PTY session lifecycle: create, resize, write, close, output fanout - 256-bit token auth, multi-client support, session persistence - TypeScript IPC client at clients/ts/pty-client.ts (Bun + Node.js) - Protocol: 9 client messages, 7 daemon messages - Based on tribunal ruling (78% confidence, 4 rounds, 55 objections) WIP: Rust implementation in progress (protocol.rs + auth.rs done)
This commit is contained in:
parent
e8132b7dc6
commit
4b5583430d
6 changed files with 861 additions and 0 deletions
233
agor-pty/clients/ts/pty-client.ts
Normal file
233
agor-pty/clients/ts/pty-client.ts
Normal file
|
|
@ -0,0 +1,233 @@
|
|||
/**
|
||||
* 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<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. */
|
||||
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<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));
|
||||
* }
|
||||
*/
|
||||
export async function* ptySession(
|
||||
sessionId: string,
|
||||
opts?: { shell?: string; cwd?: string; cols?: number; rows?: number; socketDir?: string }
|
||||
): AsyncGenerator<number[], 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) {
|
||||
yield msg.data;
|
||||
} 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