This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

Add @myobie/pty/client entry point for programmatic session management

New public API covering every CLI feature: SessionConnection for
bidirectional attach without stdin/stdout, sendData/peekScreen as
Promise-based alternatives, events, keys, stats, and protocol types.
Refactor spawnDaemon to options object with optional rows/cols.

Nathan Herald (Apr 6, 2026, 7:33 PM +0200) c9f31de5 b7f7c325

+1119 -29
+17
CHANGELOG.md
··· 2 2 3 3 ## Unreleased 4 4 5 + ### Client API (`@myobie/pty/client`) 6 + - New `@myobie/pty/client` entry point for programmatic session management — no TUI framework dependency required 7 + - `SessionConnection` class for bidirectional session connections without taking over stdin/stdout 8 + - `sendData()` — Promise-based alternative to CLI send (no `process.exit()`) 9 + - `peekScreen()` — Promise-based screen capture (no stdout writes) 10 + - Export `queryStats`, `attach`, `peek`, `send` from client API 11 + - Export `PtyServer` and `ServerOptions` for embedding 12 + - Export events system: `EventType`, `EventRecord`, `EventFollower`, `readRecentEvents`, `formatEvent`, and all event subtypes 13 + - Export key resolution: `resolveKey`, `parseSeqValue` 14 + - Export session helpers: `validateName`, `cleanupAll`, `cleanupSocket`, `getSocketPath` 15 + - Export protocol types: `PacketReader`, `MessageType`, `Packet` 16 + - `spawnDaemon` now takes an options object with optional `rows`/`cols` (breaking change from positional args) 17 + 18 + ### CLI improvements 5 19 - Prevent accidental session nesting: `pty run` inside an existing session execs the command directly instead of creating a nested session (`-d` bypasses the check) 6 20 - Set `PTY_SESSION` env var in child processes so they can detect they're inside a pty session 7 21 - Add CPU and memory usage to `pty stats` (child process and daemon, via `ps`) 8 22 - Add process PIDs to `pty stats` output 23 + - Gracefully handle older daemons that don't report resource usage 24 + 25 + ### Events 9 26 - Add terminal event logging — sessions capture bell, title changes, desktop notifications (OSC 9/99/777), focus requests, and cursor visibility transitions to a per-session JSONL file 10 27 - Add `pty events <name>` command to follow events in real-time (like `tail -f`) 11 28 - Add `pty events --all` to follow events from all sessions, interleaved
+68
README.md
··· 124 124 125 125 Event files auto-truncate at 1,000 lines and are cleaned up with the 24-hour dead session TTL. 126 126 127 + ## Client API 128 + 129 + @myobie/pty exposes a programmatic TypeScript API for building apps on top of pty sessions. Import from `@myobie/pty/client`. 130 + 131 + ```typescript 132 + import { 133 + spawnDaemon, listSessions, getSession, 134 + SessionConnection, sendData, peekScreen, queryStats, 135 + EventFollower, readRecentEvents, 136 + resolveKey, 137 + } from "@myobie/pty/client"; 138 + ``` 139 + 140 + ### Managing sessions 141 + 142 + ```typescript 143 + // Create a session 144 + await spawnDaemon({ 145 + name: "myserver", 146 + command: "node", 147 + args: ["server.js"], 148 + displayCommand: "node server.js", 149 + cwd: "/path/to/project", 150 + rows: 24, 151 + cols: 80, 152 + }); 153 + 154 + // List and query 155 + const sessions = await listSessions(); 156 + const stats = await queryStats("myserver"); 157 + ``` 158 + 159 + ### Connecting to a session 160 + 161 + `SessionConnection` provides a bidirectional, event-driven connection without taking over stdin/stdout — ideal for GUI apps, multiplexers, or web interfaces: 162 + 163 + ```typescript 164 + const conn = new SessionConnection({ name: "myserver", rows: 24, cols: 80 }); 165 + const initialScreen = await conn.connect(); 166 + 167 + conn.on("data", (data) => myTerminalView.write(data)); 168 + conn.on("exit", (code) => console.log(`Exited: ${code}`)); 169 + 170 + conn.write("hello\r"); 171 + conn.press("ctrl+c"); 172 + conn.resize(30, 100); 173 + conn.disconnect(); 174 + ``` 175 + 176 + For simpler operations: 177 + 178 + ```typescript 179 + await sendData({ name: "myserver", data: ["hello\r"] }); 180 + const screen = await peekScreen({ name: "myserver", plain: true }); 181 + ``` 182 + 183 + ### Following events 184 + 185 + ```typescript 186 + const follower = new EventFollower({ 187 + names: ["myserver"], 188 + onEvent: (event) => console.log(event.type, event.ts), 189 + }); 190 + follower.start(); 191 + ``` 192 + 193 + See **[docs/client.md](docs/client.md)** for the full API reference. 194 + 127 195 ## Testing Library 128 196 129 197 @myobie/pty includes a terminal testing library — like Playwright, but for the terminal. Spawn any process in a real PTY, send keystrokes, take screenshots, assert on visible output.
+322
docs/client.md
··· 1 + # Client API Reference 2 + 3 + Import from `@myobie/pty/client`. 4 + 5 + ```typescript 6 + import { SessionConnection, spawnDaemon, listSessions } from "@myobie/pty/client"; 7 + ``` 8 + 9 + ## Session Management 10 + 11 + ### `listSessions(): Promise<SessionInfo[]>` 12 + 13 + List all sessions (running + exited within 24h). 14 + 15 + ### `getSession(name: string): Promise<SessionInfo | null>` 16 + 17 + Get a single session by name. 18 + 19 + ### `validateName(name: string): void` 20 + 21 + Throws if the name is invalid. Names must match `[a-zA-Z0-9._-]` and be at most 255 characters. 22 + 23 + ### `getSessionDir(): string` 24 + 25 + Returns the session directory path (`PTY_SESSION_DIR` env var or `~/.local/state/pty`). 26 + 27 + ### `getSocketPath(name: string): string` 28 + 29 + Returns the Unix socket path for a session. 30 + 31 + ### `cleanupSocket(name: string): void` 32 + 33 + Remove a session's `.sock` and `.pid` files. 34 + 35 + ### `cleanupAll(name: string): void` 36 + 37 + Remove all files for a session (socket, pid, metadata, events, lock). 38 + 39 + ### Types 40 + 41 + ```typescript 42 + interface SessionInfo { 43 + name: string; 44 + socketPath: string; 45 + pid: number | null; 46 + status: "running" | "exited"; 47 + metadata: SessionMetadata | null; 48 + } 49 + 50 + interface SessionMetadata { 51 + command: string; 52 + args: string[]; 53 + displayCommand: string; 54 + cwd: string; 55 + createdAt: string; 56 + exitCode?: number; 57 + exitedAt?: string; 58 + lastLines?: string[]; 59 + } 60 + ``` 61 + 62 + ## Session Creation 63 + 64 + ### `spawnDaemon(options: SpawnDaemonOptions): Promise<void>` 65 + 66 + Spawn a new session daemon. Resolves once the daemon is listening. 67 + 68 + ```typescript 69 + interface SpawnDaemonOptions { 70 + name: string; 71 + command: string; 72 + args: string[]; 73 + displayCommand: string; 74 + cwd?: string; // defaults to process.cwd() 75 + ephemeral?: boolean; // auto-remove on exit 76 + rows?: number; // defaults to process.stdout.rows ?? 24 77 + cols?: number; // defaults to process.stdout.columns ?? 80 78 + } 79 + ``` 80 + 81 + ### `resolveCommand(cmd: string): string` 82 + 83 + Resolve a command name to an absolute path (like `which`). Throws if not found. 84 + 85 + ### `waitForSocket(name: string, timeoutMs: number, earlyCheck?: () => void): Promise<void>` 86 + 87 + Wait for a session's Unix socket to appear on disk. 88 + 89 + ### `PtyServer` 90 + 91 + The server class itself, for embedding a pty server directly (without the daemon process): 92 + 93 + ```typescript 94 + import { PtyServer } from "@myobie/pty/client"; 95 + 96 + const server = new PtyServer({ 97 + name: "embedded", 98 + command: "bash", 99 + args: [], 100 + displayCommand: "bash", 101 + cwd: process.cwd(), 102 + rows: 24, 103 + cols: 80, 104 + onExit: (code) => console.log(`Exited: ${code}`), 105 + }); 106 + 107 + await server.ready; 108 + // server is now listening on its Unix socket 109 + ``` 110 + 111 + ## Session Interaction (Programmatic) 112 + 113 + These functions do not use `process.stdin`, `process.stdout`, or call `process.exit()`. Safe for use in GUI apps, servers, and libraries. 114 + 115 + ### `SessionConnection` 116 + 117 + Bidirectional, event-driven connection to a session. 118 + 119 + ```typescript 120 + const conn = new SessionConnection({ name: "myserver", rows: 24, cols: 80 }); 121 + const initialScreen = await conn.connect(); 122 + 123 + conn.on("data", (data: string) => { /* terminal output */ }); 124 + conn.on("exit", (code: number) => { /* process exited */ }); 125 + conn.on("close", () => { /* connection closed */ }); 126 + conn.on("error", (err: Error) => { /* connection error */ }); 127 + 128 + conn.write("hello\r"); // send raw data 129 + conn.press("ctrl+c"); // send named key 130 + conn.resize(30, 100); // resize terminal 131 + conn.disconnect(); // close connection 132 + ``` 133 + 134 + **Properties:** 135 + - `connected: boolean` — whether the connection is active 136 + 137 + **Events:** 138 + | Event | Payload | Description | 139 + |---|---|---| 140 + | `data` | `string` | Terminal output from the session | 141 + | `screen` | `string` | Initial screen replay on connect | 142 + | `exit` | `number` | Session process exited with code | 143 + | `close` | — | Connection closed | 144 + | `error` | `Error` | Connection error | 145 + 146 + ### `sendData(options: SendDataOptions): Promise<void>` 147 + 148 + Send data to a session without connecting interactively. Resolves on success, rejects on error. 149 + 150 + ```typescript 151 + await sendData({ name: "myserver", data: ["hello\r"] }); 152 + 153 + // With delay between items 154 + await sendData({ name: "myserver", data: ["git status\r", "git diff\r"], delayMs: 500 }); 155 + ``` 156 + 157 + ### `peekScreen(options: PeekScreenOptions): Promise<string>` 158 + 159 + Get the current screen content as a string. 160 + 161 + ```typescript 162 + const screen = await peekScreen({ name: "myserver" }); // ANSI output 163 + const plain = await peekScreen({ name: "myserver", plain: true }); // plain text 164 + ``` 165 + 166 + ### `queryStats(name: string, timeoutMs?: number): Promise<StatsResult>` 167 + 168 + Query live metrics from a running session. 169 + 170 + ```typescript 171 + interface StatsResult { 172 + name: string; 173 + terminal: { 174 + cols: number; rows: number; 175 + cursorX: number; cursorY: number; 176 + scrollbackUsed: number; scrollbackCapacity: number; 177 + }; 178 + process: { 179 + alive: boolean; exitCode: number | null; 180 + pid: number | null; 181 + resources: ProcessResources | null; 182 + }; 183 + daemon: { 184 + pid: number; 185 + resources: ProcessResources | null; 186 + }; 187 + clients: { total: number; attached: number; readOnly: number }; 188 + modes: { 189 + sgrMouse: boolean; cursorHidden: boolean; 190 + kittyKeyboard: boolean; kittyKeyboardFlags: number[]; 191 + }; 192 + uptimeSeconds: number | null; 193 + createdAt: string | null; 194 + } 195 + 196 + interface ProcessResources { 197 + rssKb: number; 198 + cpuPercent: number; 199 + } 200 + ``` 201 + 202 + ## Session Interaction (CLI-oriented) 203 + 204 + These functions use `process.stdin`/`process.stdout` directly and may call `process.exit()`. They are re-exported for tools that want CLI-like behavior. 205 + 206 + ### `attach(options: AttachOptions): void` 207 + 208 + Interactive attach with bidirectional I/O. Takes over stdin/stdout. Ctrl+\ to detach (double-tap to send through). 209 + 210 + ### `peek(options: PeekOptions): void` 211 + 212 + Read-only view. Writes directly to stdout. 213 + 214 + ### `send(options: SendOptions): void` 215 + 216 + Send data to a session. Calls `process.exit(0)` on success, `process.exit(1)` on error. 217 + 218 + ## Events 219 + 220 + ### `EventFollower` 221 + 222 + Follow events from one or more sessions in real-time. 223 + 224 + ```typescript 225 + const follower = new EventFollower({ 226 + names: ["myserver"], // or omit for all sessions 227 + onEvent: (event) => { 228 + console.log(event.type, event.ts); 229 + }, 230 + }); 231 + follower.start(); 232 + // later: 233 + follower.stop(); 234 + ``` 235 + 236 + ### `readRecentEvents(name: string, count?: number): EventRecord[]` 237 + 238 + Read the last N events (default 50) for a session. 239 + 240 + ### `formatEvent(event: EventRecord): string` 241 + 242 + Format an event for console output with timestamp. 243 + 244 + ### `EventType` 245 + 246 + ```typescript 247 + const EventType = { 248 + BELL: "bell", 249 + TITLE_CHANGE: "title_change", 250 + NOTIFICATION: "notification", 251 + FOCUS_REQUEST: "focus_request", 252 + CURSOR_VISIBLE: "cursor_visible", 253 + }; 254 + ``` 255 + 256 + ### Event types 257 + 258 + ```typescript 259 + type EventRecord = 260 + | BellEvent 261 + | TitleChangeEvent 262 + | NotificationEvent 263 + | FocusRequestEvent 264 + | CursorVisibleEvent; 265 + ``` 266 + 267 + Each extends `EventBase { session: string; type: EventType; ts: string }`. 268 + 269 + `NotificationEvent` adds `title?`, `body?`, `source?: "osc9" | "osc99" | "osc777"`. 270 + `TitleChangeEvent` adds `value: string`. 271 + 272 + ## Keys 273 + 274 + ### `resolveKey(spec: string): string` 275 + 276 + Resolve a key name to its byte sequence. Supports: 277 + 278 + - Named keys: `return`, `tab`, `escape`, `space`, `backspace`, `delete` 279 + - Arrows: `up`, `down`, `left`, `right` 280 + - Navigation: `home`, `end`, `pageup`, `pagedown` 281 + - Modifiers: `ctrl+c`, `alt+x`, `shift+a` 282 + 283 + ### `parseSeqValue(value: string): string` 284 + 285 + If value starts with `key:`, resolves the key name. Otherwise returns the literal string. 286 + 287 + ## Protocol (Advanced) 288 + 289 + Low-level protocol types for building custom clients. 290 + 291 + ### `PacketReader` 292 + 293 + Streaming packet parser. Feed raw socket data, get parsed packets. 294 + 295 + ```typescript 296 + const reader = new PacketReader(); 297 + socket.on("data", (raw) => { 298 + const packets = reader.feed(raw); 299 + for (const packet of packets) { 300 + // packet.type: MessageType, packet.payload: Buffer 301 + } 302 + }); 303 + ``` 304 + 305 + ### `MessageType` 306 + 307 + ```typescript 308 + const MessageType = { 309 + DATA: 0, // Terminal output / input 310 + ATTACH: 1, // Client attach with size 311 + DETACH: 2, // Client detach 312 + RESIZE: 3, // Terminal resize 313 + EXIT: 4, // Process exited 314 + SCREEN: 5, // Screen replay 315 + PEEK: 6, // Read-only peek request 316 + STATUS: 7, // Stats query/response 317 + }; 318 + ``` 319 + 320 + ### `TERMINAL_SANITIZE: string` 321 + 322 + ANSI sequence that resets all terminal modes (mouse tracking, cursor visibility, alternate screen, etc.). Useful after disconnecting from a session.
+4
package.json
··· 36 36 "./tui": { 37 37 "types": "./dist/tui/index.d.ts", 38 38 "default": "./dist/tui/index.js" 39 + }, 40 + "./client": { 41 + "types": "./dist/client-api.d.ts", 42 + "default": "./dist/client-api.js" 39 43 } 40 44 }, 41 45 "scripts": {
+3 -3
src/cli.ts
··· 480 480 } 481 481 482 482 try { 483 - await spawnDaemon(name, command, args, displayCommand, previousCwd, ephemeral); 483 + await spawnDaemon({ name, command, args, displayCommand, cwd: previousCwd, ephemeral }); 484 484 } finally { 485 485 releaseLock(name); 486 486 } ··· 551 551 552 552 // Restart 553 553 cleanupAll(session.name); 554 - await spawnDaemon(session.name, meta.command, meta.args, meta.displayCommand, meta.cwd); 554 + await spawnDaemon({ name: session.name, command: meta.command, args: meta.args, displayCommand: meta.displayCommand, cwd: meta.cwd }); 555 555 console.log(`Session "${session.name}" restarted.`); 556 556 doAttach(session.name); 557 557 } ··· 860 860 } 861 861 862 862 cleanupAll(name); 863 - await spawnDaemon(name, meta.command, meta.args, meta.displayCommand, meta.cwd); 863 + await spawnDaemon({ name, command: meta.command, args: meta.args, displayCommand: meta.displayCommand, cwd: meta.cwd }); 864 864 console.log(`Session "${name}" restarted.`); 865 865 doAttach(name); 866 866 }
+47
src/client-api.ts
··· 1 + // Public API for programmatic session management. 2 + // Import from "@myobie/pty/client". 3 + 4 + // Session management 5 + export { 6 + listSessions, getSession, validateName, 7 + getSessionDir, getSocketPath, 8 + cleanupSocket, cleanupAll, 9 + type SessionInfo, type SessionMetadata, 10 + } from "./sessions.ts"; 11 + 12 + // Session creation 13 + export { spawnDaemon, resolveCommand, waitForSocket, type SpawnDaemonOptions } from "./spawn.ts"; 14 + export { PtyServer, type ServerOptions } from "./server.ts"; 15 + 16 + // Session interaction (programmatic — no process.exit, no stdin/stdout) 17 + export { 18 + SessionConnection, sendData, peekScreen, 19 + type SessionConnectionOptions, type SendDataOptions, type PeekScreenOptions, 20 + } from "./connection.ts"; 21 + 22 + // Session interaction (CLI-oriented — uses process.stdin/stdout, may call process.exit) 23 + export { 24 + attach, peek, send, queryStats, 25 + TERMINAL_SANITIZE, 26 + type AttachOptions, type PeekOptions, type SendOptions, 27 + type StatsResult, type ProcessResources, 28 + } from "./client.ts"; 29 + 30 + // Events 31 + export { 32 + EventType, 33 + EventFollower, readRecentEvents, formatEvent, 34 + type EventRecord, type EventBase, 35 + type BellEvent, type TitleChangeEvent, type NotificationEvent, 36 + type FocusRequestEvent, type CursorVisibleEvent, 37 + type FollowerOptions, 38 + } from "./events.ts"; 39 + 40 + // Keys 41 + export { resolveKey, parseSeqValue } from "./keys.ts"; 42 + 43 + // Protocol (advanced) 44 + export { 45 + PacketReader, MessageType, 46 + type Packet, 47 + } from "./protocol.ts";
+212
src/connection.ts
··· 1 + import * as net from "node:net"; 2 + import { EventEmitter } from "node:events"; 3 + import { 4 + MessageType, 5 + PacketReader, 6 + encodeAttach, 7 + encodeData, 8 + encodeDetach, 9 + encodePeek, 10 + encodeResize, 11 + decodeExit, 12 + } from "./protocol.ts"; 13 + import { getSocketPath } from "./sessions.ts"; 14 + import { resolveKey } from "./keys.ts"; 15 + 16 + export interface SessionConnectionOptions { 17 + name: string; 18 + rows: number; 19 + cols: number; 20 + } 21 + 22 + export interface SendDataOptions { 23 + name: string; 24 + data: string[]; 25 + delayMs?: number; 26 + } 27 + 28 + export interface PeekScreenOptions { 29 + name: string; 30 + plain?: boolean; 31 + } 32 + 33 + /** 34 + * Programmatic bidirectional connection to a pty session. 35 + * Unlike the CLI `attach()`, this does not take over stdin/stdout 36 + * or call process.exit(). 37 + * 38 + * Events: 39 + * - 'data' (data: string) — terminal output from the session 40 + * - 'screen' (screen: string) — initial screen replay on connect 41 + * - 'exit' (code: number) — session process exited 42 + * - 'close' () — connection closed 43 + * - 'error' (err: Error) — connection error 44 + */ 45 + export class SessionConnection extends EventEmitter { 46 + private socket: net.Socket | null = null; 47 + private reader = new PacketReader(); 48 + private _connected = false; 49 + private options: SessionConnectionOptions; 50 + 51 + constructor(options: SessionConnectionOptions) { 52 + super(); 53 + this.options = options; 54 + } 55 + 56 + get connected(): boolean { 57 + return this._connected; 58 + } 59 + 60 + connect(): Promise<string> { 61 + return new Promise((resolve, reject) => { 62 + const socketPath = getSocketPath(this.options.name); 63 + const socket = net.createConnection(socketPath); 64 + this.socket = socket; 65 + 66 + socket.on("connect", () => { 67 + this._connected = true; 68 + socket.write(encodeAttach(this.options.rows, this.options.cols)); 69 + }); 70 + 71 + let initialScreenResolved = false; 72 + 73 + socket.on("data", (raw: Buffer) => { 74 + const packets = this.reader.feed(raw); 75 + for (const packet of packets) { 76 + switch (packet.type) { 77 + case MessageType.SCREEN: { 78 + const screen = packet.payload.toString(); 79 + if (!initialScreenResolved) { 80 + initialScreenResolved = true; 81 + resolve(screen); 82 + } 83 + this.emit("screen", screen); 84 + break; 85 + } 86 + case MessageType.DATA: 87 + this.emit("data", packet.payload.toString()); 88 + break; 89 + case MessageType.EXIT: { 90 + const code = decodeExit(packet.payload); 91 + this.emit("exit", code); 92 + break; 93 + } 94 + } 95 + } 96 + }); 97 + 98 + socket.on("error", (err: NodeJS.ErrnoException) => { 99 + this._connected = false; 100 + let error: Error; 101 + if (err.code === "ENOENT" || err.code === "ECONNREFUSED") { 102 + error = new Error(`Session "${this.options.name}" not found or not running.`); 103 + } else { 104 + error = new Error(`Connection error: ${err.message}`); 105 + } 106 + if (!initialScreenResolved) { 107 + initialScreenResolved = true; 108 + reject(error); 109 + } 110 + this.emit("error", error); 111 + }); 112 + 113 + socket.on("close", () => { 114 + this._connected = false; 115 + this.socket = null; 116 + if (!initialScreenResolved) { 117 + initialScreenResolved = true; 118 + reject(new Error(`Connection to "${this.options.name}" closed before screen received.`)); 119 + } 120 + this.emit("close"); 121 + }); 122 + }); 123 + } 124 + 125 + write(data: string): void { 126 + if (!this.socket || !this._connected) return; 127 + this.socket.write(encodeData(data)); 128 + } 129 + 130 + press(key: string): void { 131 + this.write(resolveKey(key)); 132 + } 133 + 134 + resize(rows: number, cols: number): void { 135 + if (!this.socket || !this._connected) return; 136 + this.socket.write(encodeResize(rows, cols)); 137 + } 138 + 139 + disconnect(): void { 140 + if (!this.socket) return; 141 + this.socket.write(encodeDetach()); 142 + this.socket.destroy(); 143 + this._connected = false; 144 + this.socket = null; 145 + } 146 + } 147 + 148 + /** Send data to a session. Promise-based alternative to the CLI send(). */ 149 + export function sendData(options: SendDataOptions): Promise<void> { 150 + return new Promise((resolve, reject) => { 151 + const socketPath = getSocketPath(options.name); 152 + const socket = net.createConnection(socketPath); 153 + 154 + socket.on("connect", async () => { 155 + for (let i = 0; i < options.data.length; i++) { 156 + if (i > 0 && options.delayMs) { 157 + await new Promise((r) => setTimeout(r, options.delayMs)); 158 + } 159 + socket.write(encodeData(options.data[i])); 160 + } 161 + socket.end(); 162 + }); 163 + 164 + socket.on("error", (err: NodeJS.ErrnoException) => { 165 + if (err.code === "ENOENT" || err.code === "ECONNREFUSED") { 166 + reject(new Error(`Session "${options.name}" not found or not running.`)); 167 + } else { 168 + reject(new Error(`Connection error: ${err.message}`)); 169 + } 170 + }); 171 + 172 + socket.on("close", () => { 173 + resolve(); 174 + }); 175 + }); 176 + } 177 + 178 + /** Get the current screen content. Promise-based alternative to the CLI peek(). */ 179 + export function peekScreen(options: PeekScreenOptions): Promise<string> { 180 + return new Promise((resolve, reject) => { 181 + const socketPath = getSocketPath(options.name); 182 + const reader = new PacketReader(); 183 + const socket = net.createConnection(socketPath); 184 + 185 + socket.on("connect", () => { 186 + socket.write(encodePeek(options.plain)); 187 + }); 188 + 189 + socket.on("data", (raw: Buffer) => { 190 + const packets = reader.feed(raw); 191 + for (const packet of packets) { 192 + if (packet.type === MessageType.SCREEN) { 193 + socket.destroy(); 194 + resolve(packet.payload.toString()); 195 + return; 196 + } 197 + } 198 + }); 199 + 200 + socket.on("error", (err: NodeJS.ErrnoException) => { 201 + if (err.code === "ENOENT" || err.code === "ECONNREFUSED") { 202 + reject(new Error(`Session "${options.name}" not found or not running.`)); 203 + } else { 204 + reject(new Error(`Connection error: ${err.message}`)); 205 + } 206 + }); 207 + 208 + socket.on("close", () => { 209 + reject(new Error(`Connection to "${options.name}" closed before screen received.`)); 210 + }); 211 + }); 212 + }
+6 -6
src/events.ts
··· 12 12 13 13 export type EventType = (typeof EventType)[keyof typeof EventType]; 14 14 15 - interface EventBase { 15 + export interface EventBase { 16 16 session: string; 17 17 type: EventType; 18 18 ts: string; 19 19 } 20 20 21 - interface BellEvent extends EventBase { 21 + export interface BellEvent extends EventBase { 22 22 type: "bell"; 23 23 } 24 24 25 - interface TitleChangeEvent extends EventBase { 25 + export interface TitleChangeEvent extends EventBase { 26 26 type: "title_change"; 27 27 value: string; 28 28 } 29 29 30 - interface NotificationEvent extends EventBase { 30 + export interface NotificationEvent extends EventBase { 31 31 type: "notification"; 32 32 title?: string; 33 33 body?: string; 34 34 source?: "osc9" | "osc99" | "osc777"; 35 35 } 36 36 37 - interface FocusRequestEvent extends EventBase { 37 + export interface FocusRequestEvent extends EventBase { 38 38 type: "focus_request"; 39 39 } 40 40 41 - interface CursorVisibleEvent extends EventBase { 41 + export interface CursorVisibleEvent extends EventBase { 42 42 type: "cursor_visible"; 43 43 } 44 44
+21 -17
src/spawn.ts
··· 7 7 8 8 const __dirname = path.dirname(fileURLToPath(import.meta.url)); 9 9 10 - export async function spawnDaemon( 11 - name: string, 12 - command: string, 13 - args: string[], 14 - displayCommand: string, 15 - cwd?: string, 16 - ephemeral = false, 17 - ): Promise<void> { 10 + export interface SpawnDaemonOptions { 11 + name: string; 12 + command: string; 13 + args: string[]; 14 + displayCommand: string; 15 + cwd?: string; 16 + ephemeral?: boolean; 17 + rows?: number; 18 + cols?: number; 19 + } 20 + 21 + export async function spawnDaemon(options: SpawnDaemonOptions): Promise<void> { 18 22 const stdout = process.stdout as tty.WriteStream; 19 - const rows = stdout.rows ?? 24; 20 - const cols = stdout.columns ?? 80; 23 + const rows = options.rows ?? stdout.rows ?? 24; 24 + const cols = options.cols ?? stdout.columns ?? 80; 21 25 22 26 const serverModule = path.join(__dirname, "server.js"); 23 27 const config = JSON.stringify({ 24 - name, 25 - command, 26 - args, 27 - displayCommand, 28 - cwd: cwd ?? process.cwd(), 28 + name: options.name, 29 + command: options.command, 30 + args: options.args, 31 + displayCommand: options.displayCommand, 32 + cwd: options.cwd ?? process.cwd(), 29 33 rows, 30 34 cols, 31 - ephemeral, 35 + ephemeral: options.ephemeral ?? false, 32 36 }); 33 37 34 38 const child = spawn(process.execPath, [serverModule], { ··· 54 58 (child.stderr as any)?.unref?.(); 55 59 child.unref(); 56 60 57 - await waitForSocket(name, 3000, () => { 61 + await waitForSocket(options.name, 3000, () => { 58 62 if (earlyExit) { 59 63 const details = stderrOutput.trim(); 60 64 const msg = `Daemon process exited immediately (code ${earlyExitCode ?? "unknown"}).`;
+1 -1
src/tui/index.ts
··· 96 96 } from "../sessions.ts"; 97 97 98 98 // Daemon spawning 99 - export { spawnDaemon } from "../spawn.ts"; 99 + export { spawnDaemon, type SpawnDaemonOptions } from "../spawn.ts";
+2 -2
src/tui/interactive.ts
··· 506 506 } 507 507 cleanupAll(session.name); 508 508 try { 509 - await spawnDaemon(session.name, meta.command, meta.args, meta.displayCommand, meta.cwd); 509 + await spawnDaemon({ name: session.name, command: meta.command, args: meta.args, displayCommand: meta.displayCommand, cwd: meta.cwd }); 510 510 } catch { 511 511 // Refresh list to show updated state 512 512 const updated = await listSessions(); ··· 589 589 const shellArgs = ["-c", command]; 590 590 591 591 try { 592 - await spawnDaemon(name, shellCmd, shellArgs, command, dir); 592 + await spawnDaemon({ name, command: shellCmd, args: shellArgs, displayCommand: command, cwd: dir }); 593 593 } catch (e: any) { 594 594 releaseLock(name); 595 595 console.error(e.message);
+264
tests/connection.test.ts
··· 1 + import { describe, it, expect, afterEach, afterAll } from "vitest"; 2 + import * as fs from "node:fs"; 3 + import * as os from "node:os"; 4 + import * as path from "node:path"; 5 + import { fileURLToPath } from "node:url"; 6 + import { spawn } from "node:child_process"; 7 + import { 8 + SessionConnection, 9 + sendData, 10 + peekScreen, 11 + } from "../src/connection.ts"; 12 + 13 + const __dirname = path.dirname(fileURLToPath(import.meta.url)); 14 + const nodeBin = process.execPath; 15 + const serverModule = path.join(__dirname, "..", "dist", "server.js"); 16 + 17 + const testRoot = fs.mkdtempSync(path.join(os.tmpdir(), "pty-conn-")); 18 + afterAll(() => { 19 + fs.rmSync(testRoot, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); 20 + }); 21 + 22 + let bgPids: number[] = []; 23 + let sessionDirs: string[] = []; 24 + 25 + function makeSessionDir(): string { 26 + const dir = fs.mkdtempSync(path.join(testRoot, "d-")); 27 + sessionDirs.push(dir); 28 + return dir; 29 + } 30 + 31 + let nameCounter = 0; 32 + function uniqueName(): string { 33 + return `conn${++nameCounter}-${Math.random().toString(36).slice(2, 6)}`; 34 + } 35 + 36 + async function startDaemon( 37 + sessionDir: string, 38 + name: string, 39 + command: string, 40 + args: string[] = [], 41 + ): Promise<number> { 42 + const config = JSON.stringify({ 43 + name, 44 + command, 45 + args, 46 + displayCommand: command, 47 + cwd: os.tmpdir(), 48 + rows: 24, 49 + cols: 80, 50 + }); 51 + 52 + const child = spawn(nodeBin, [serverModule], { 53 + detached: true, 54 + stdio: ["ignore", "ignore", "pipe"], 55 + env: { 56 + ...process.env, 57 + PTY_SERVER_CONFIG: config, 58 + PTY_SESSION_DIR: sessionDir, 59 + }, 60 + }); 61 + 62 + let stderr = ""; 63 + child.stderr?.on("data", (d: Buffer) => { stderr += d.toString(); }); 64 + let exitCode: number | null = null; 65 + child.on("exit", (code) => { exitCode = code; }); 66 + (child.stderr as any)?.unref?.(); 67 + child.unref(); 68 + 69 + const socketPath = path.join(sessionDir, `${name}.sock`); 70 + const start = Date.now(); 71 + while (Date.now() - start < 5000) { 72 + if (exitCode !== null) { 73 + throw new Error(`Daemon exited with code ${exitCode}. stderr:\n${stderr}`); 74 + } 75 + try { 76 + fs.statSync(socketPath); 77 + await new Promise((r) => setTimeout(r, 100)); 78 + bgPids.push(child.pid!); 79 + return child.pid!; 80 + } catch {} 81 + await new Promise((r) => setTimeout(r, 50)); 82 + } 83 + throw new Error(`Timeout waiting for daemon socket: ${socketPath}`); 84 + } 85 + 86 + afterEach(() => { 87 + for (const pid of bgPids) { 88 + try { process.kill(pid, "SIGTERM"); } catch {} 89 + } 90 + bgPids = []; 91 + for (const dir of sessionDirs) { 92 + try { 93 + const entries = fs.readdirSync(dir); 94 + for (const e of entries) { 95 + try { fs.unlinkSync(path.join(dir, e)); } catch {} 96 + } 97 + } catch {} 98 + } 99 + sessionDirs = []; 100 + }); 101 + 102 + describe("SessionConnection", () => { 103 + it("connects and receives initial screen", async () => { 104 + const dir = makeSessionDir(); 105 + const name = uniqueName(); 106 + process.env.PTY_SESSION_DIR = dir; 107 + await startDaemon(dir, name, "sh", ["-c", "echo hello-screen; exec cat"]); 108 + await new Promise((r) => setTimeout(r, 300)); 109 + 110 + const conn = new SessionConnection({ name, rows: 24, cols: 80 }); 111 + const screen = await conn.connect(); 112 + 113 + expect(screen).toContain("hello-screen"); 114 + expect(conn.connected).toBe(true); 115 + conn.disconnect(); 116 + expect(conn.connected).toBe(false); 117 + }, 15000); 118 + 119 + it("receives data events after connect", async () => { 120 + const dir = makeSessionDir(); 121 + const name = uniqueName(); 122 + process.env.PTY_SESSION_DIR = dir; 123 + await startDaemon(dir, name, "cat"); 124 + 125 + const conn = new SessionConnection({ name, rows: 24, cols: 80 }); 126 + await conn.connect(); 127 + 128 + const received: string[] = []; 129 + conn.on("data", (data: string) => { received.push(data); }); 130 + 131 + conn.write("test-input"); 132 + await new Promise((r) => setTimeout(r, 300)); 133 + 134 + expect(received.join("")).toContain("test-input"); 135 + conn.disconnect(); 136 + }, 15000); 137 + 138 + it("press() sends named keys", async () => { 139 + const dir = makeSessionDir(); 140 + const name = uniqueName(); 141 + process.env.PTY_SESSION_DIR = dir; 142 + await startDaemon(dir, name, "cat"); 143 + 144 + const conn = new SessionConnection({ name, rows: 24, cols: 80 }); 145 + await conn.connect(); 146 + 147 + const received: string[] = []; 148 + conn.on("data", (data: string) => { received.push(data); }); 149 + 150 + // cat echoes everything, including the return key as a newline 151 + conn.write("hello"); 152 + conn.press("return"); 153 + await new Promise((r) => setTimeout(r, 300)); 154 + 155 + const output = received.join(""); 156 + expect(output).toContain("hello"); 157 + expect(output).toContain("\r"); 158 + conn.disconnect(); 159 + }, 15000); 160 + 161 + it("emits exit when process exits", async () => { 162 + const dir = makeSessionDir(); 163 + const name = uniqueName(); 164 + process.env.PTY_SESSION_DIR = dir; 165 + // Process that exits after a short delay 166 + await startDaemon(dir, name, "sh", ["-c", "sleep 0.2; exit 7"]); 167 + 168 + const conn = new SessionConnection({ name, rows: 24, cols: 80 }); 169 + await conn.connect(); 170 + 171 + const exitCode = await new Promise<number>((resolve) => { 172 + conn.on("exit", resolve); 173 + }); 174 + 175 + expect(exitCode).toBe(7); 176 + conn.disconnect(); 177 + }, 15000); 178 + 179 + it("rejects on nonexistent session", async () => { 180 + const dir = makeSessionDir(); 181 + process.env.PTY_SESSION_DIR = dir; 182 + 183 + const conn = new SessionConnection({ name: "nonexistent", rows: 24, cols: 80 }); 184 + // Suppress the 'error' event that fires after the promise rejects 185 + conn.on("error", () => {}); 186 + await expect(conn.connect()).rejects.toThrow("not found or not running"); 187 + }, 15000); 188 + 189 + it("resize sends new dimensions", async () => { 190 + const dir = makeSessionDir(); 191 + const name = uniqueName(); 192 + process.env.PTY_SESSION_DIR = dir; 193 + await startDaemon(dir, name, "cat"); 194 + 195 + const conn = new SessionConnection({ name, rows: 24, cols: 80 }); 196 + await conn.connect(); 197 + 198 + // Should not throw 199 + conn.resize(30, 100); 200 + await new Promise((r) => setTimeout(r, 100)); 201 + 202 + conn.disconnect(); 203 + }, 15000); 204 + }); 205 + 206 + describe("sendData", () => { 207 + it("sends text to a session and resolves", async () => { 208 + const dir = makeSessionDir(); 209 + const name = uniqueName(); 210 + process.env.PTY_SESSION_DIR = dir; 211 + await startDaemon(dir, name, "cat"); 212 + 213 + await sendData({ name, data: ["hello-send"] }); 214 + 215 + // Verify data was received via peek 216 + await new Promise((r) => setTimeout(r, 200)); 217 + const screen = await peekScreen({ name, plain: true }); 218 + expect(screen).toContain("hello-send"); 219 + }, 15000); 220 + 221 + it("rejects for nonexistent session", async () => { 222 + const dir = makeSessionDir(); 223 + process.env.PTY_SESSION_DIR = dir; 224 + 225 + await expect( 226 + sendData({ name: "nonexistent", data: ["test"] }) 227 + ).rejects.toThrow("not found or not running"); 228 + }, 15000); 229 + }); 230 + 231 + describe("peekScreen", () => { 232 + it("returns screen content", async () => { 233 + const dir = makeSessionDir(); 234 + const name = uniqueName(); 235 + process.env.PTY_SESSION_DIR = dir; 236 + await startDaemon(dir, name, "sh", ["-c", "echo peek-test; exec cat"]); 237 + await new Promise((r) => setTimeout(r, 300)); 238 + 239 + const screen = await peekScreen({ name }); 240 + expect(screen).toContain("peek-test"); 241 + }, 15000); 242 + 243 + it("returns plain text when plain=true", async () => { 244 + const dir = makeSessionDir(); 245 + const name = uniqueName(); 246 + process.env.PTY_SESSION_DIR = dir; 247 + await startDaemon(dir, name, "sh", ["-c", "echo plain-test; exec cat"]); 248 + await new Promise((r) => setTimeout(r, 300)); 249 + 250 + const screen = await peekScreen({ name, plain: true }); 251 + expect(screen).toContain("plain-test"); 252 + // Plain text should not contain ANSI escape sequences 253 + expect(screen).not.toMatch(/\x1b\[/); 254 + }, 15000); 255 + 256 + it("rejects for nonexistent session", async () => { 257 + const dir = makeSessionDir(); 258 + process.env.PTY_SESSION_DIR = dir; 259 + 260 + await expect( 261 + peekScreen({ name: "nonexistent" }) 262 + ).rejects.toThrow("not found or not running"); 263 + }, 15000); 264 + });
+152
tests/spawn-options.test.ts
··· 1 + import { describe, it, expect, afterEach, afterAll } from "vitest"; 2 + import * as fs from "node:fs"; 3 + import * as os from "node:os"; 4 + import * as path from "node:path"; 5 + import { fileURLToPath } from "node:url"; 6 + import { spawn, spawnSync } from "node:child_process"; 7 + import { queryStats } from "../src/client.ts"; 8 + 9 + const __dirname = path.dirname(fileURLToPath(import.meta.url)); 10 + const nodeBin = process.execPath; 11 + const cliPath = path.join(__dirname, "..", "dist", "cli.js"); 12 + const serverModule = path.join(__dirname, "..", "dist", "server.js"); 13 + 14 + const testRoot = fs.mkdtempSync(path.join(os.tmpdir(), "pty-spawn-opts-")); 15 + afterAll(() => { 16 + fs.rmSync(testRoot, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); 17 + }); 18 + 19 + let bgPids: number[] = []; 20 + let sessionDirs: string[] = []; 21 + 22 + function makeSessionDir(): string { 23 + const dir = fs.mkdtempSync(path.join(testRoot, "d-")); 24 + sessionDirs.push(dir); 25 + return dir; 26 + } 27 + 28 + let nameCounter = 0; 29 + function uniqueName(): string { 30 + return `spawn-opt${++nameCounter}-${Math.random().toString(36).slice(2, 6)}`; 31 + } 32 + 33 + async function startDaemonWithSize( 34 + sessionDir: string, 35 + name: string, 36 + command: string, 37 + args: string[] = [], 38 + rows = 24, 39 + cols = 80, 40 + ): Promise<number> { 41 + const config = JSON.stringify({ 42 + name, 43 + command, 44 + args, 45 + displayCommand: command, 46 + cwd: os.tmpdir(), 47 + rows, 48 + cols, 49 + }); 50 + 51 + const child = spawn(nodeBin, [serverModule], { 52 + detached: true, 53 + stdio: ["ignore", "ignore", "pipe"], 54 + env: { 55 + ...process.env, 56 + PTY_SERVER_CONFIG: config, 57 + PTY_SESSION_DIR: sessionDir, 58 + }, 59 + }); 60 + 61 + let stderr = ""; 62 + child.stderr?.on("data", (d: Buffer) => { stderr += d.toString(); }); 63 + let exitCode: number | null = null; 64 + child.on("exit", (code) => { exitCode = code; }); 65 + (child.stderr as any)?.unref?.(); 66 + child.unref(); 67 + 68 + const socketPath = path.join(sessionDir, `${name}.sock`); 69 + const start = Date.now(); 70 + while (Date.now() - start < 5000) { 71 + if (exitCode !== null) { 72 + throw new Error(`Daemon exited with code ${exitCode}. stderr:\n${stderr}`); 73 + } 74 + try { 75 + fs.statSync(socketPath); 76 + await new Promise((r) => setTimeout(r, 100)); 77 + bgPids.push(child.pid!); 78 + return child.pid!; 79 + } catch {} 80 + await new Promise((r) => setTimeout(r, 50)); 81 + } 82 + throw new Error(`Timeout waiting for daemon socket: ${socketPath}`); 83 + } 84 + 85 + afterEach(() => { 86 + for (const pid of bgPids) { 87 + try { process.kill(pid, "SIGTERM"); } catch {} 88 + } 89 + bgPids = []; 90 + for (const dir of sessionDirs) { 91 + try { 92 + const entries = fs.readdirSync(dir); 93 + for (const e of entries) { 94 + try { fs.unlinkSync(path.join(dir, e)); } catch {} 95 + } 96 + } catch {} 97 + } 98 + sessionDirs = []; 99 + }); 100 + 101 + describe("spawnDaemon options", () => { 102 + it("CLI passes through to spawnDaemon with options object", async () => { 103 + const dir = makeSessionDir(); 104 + const name = uniqueName(); 105 + 106 + // Use CLI to spawn (exercises spawnDaemon with options object internally) 107 + const result = spawnSync(nodeBin, [cliPath, "run", "-d", "--name", name, "--", "cat"], { 108 + env: { ...process.env, PTY_SESSION_DIR: dir }, 109 + encoding: "utf-8", 110 + timeout: 10000, 111 + }); 112 + 113 + expect(result.status).toBe(0); 114 + 115 + // Verify session is running 116 + process.env.PTY_SESSION_DIR = dir; 117 + const stats = await queryStats(name); 118 + expect(stats.name).toBe(name); 119 + expect(stats.process.alive).toBe(true); 120 + 121 + // Clean up 122 + const pidFile = path.join(dir, `${name}.pid`); 123 + const pid = parseInt(fs.readFileSync(pidFile, "utf-8").trim(), 10); 124 + bgPids.push(pid); 125 + }, 15000); 126 + 127 + it("custom rows and cols are applied via server config", async () => { 128 + const dir = makeSessionDir(); 129 + const name = uniqueName(); 130 + 131 + // Start daemon directly with custom dimensions 132 + await startDaemonWithSize(dir, name, "cat", [], 40, 120); 133 + 134 + process.env.PTY_SESSION_DIR = dir; 135 + const stats = await queryStats(name); 136 + expect(stats.terminal.rows).toBe(40); 137 + expect(stats.terminal.cols).toBe(120); 138 + }, 15000); 139 + 140 + it("default rows and cols are reasonable", async () => { 141 + const dir = makeSessionDir(); 142 + const name = uniqueName(); 143 + 144 + // Start daemon with default dimensions 145 + await startDaemonWithSize(dir, name, "cat"); 146 + 147 + process.env.PTY_SESSION_DIR = dir; 148 + const stats = await queryStats(name); 149 + expect(stats.terminal.rows).toBe(24); 150 + expect(stats.terminal.cols).toBe(80); 151 + }, 15000); 152 + });