This repository has no description
0

Configure Feed

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

Add pty stats command, STATUS protocol message, and 10k scrollback

Nathan Herald (Apr 2, 2026, 5:39 PM +0200) 62f13684 08f987e1

+591 -8
+150 -1
src/cli.ts
··· 3 3 import * as path from "node:path"; 4 4 import * as readline from "node:readline/promises"; 5 5 import { spawnSync } from "node:child_process"; 6 - import { attach, peek, send } from "./client.ts"; 6 + import { attach, peek, send, queryStats, type StatsResult } from "./client.ts"; 7 7 import { parseSeqValue } from "./keys.ts"; 8 8 import { 9 9 listSessions, ··· 282 282 break; 283 283 } 284 284 285 + case "stats": { 286 + let statsJson = false; 287 + let statsAll = false; 288 + let statsName: string | undefined; 289 + for (let i = 1; i < args.length; i++) { 290 + if (args[i] === "--json") statsJson = true; 291 + else if (args[i] === "--all") statsAll = true; 292 + else if (!statsName) statsName = args[i]; 293 + } 294 + await cmdStats(statsName, statsJson, statsAll); 295 + break; 296 + } 297 + 285 298 case "restart": { 286 299 const forceRestart = args[1] === "-y" || args[1] === "--yes"; 287 300 const restartName = forceRestart ? args[2] : args[1]; ··· 531 544 console.log(` ${session.name} (exited with code ${code}, ${ago}) — ${cwd}`); 532 545 } 533 546 } 547 + } 548 + 549 + async function cmdStats( 550 + name?: string, 551 + json = false, 552 + all = false, 553 + ): Promise<void> { 554 + if (name) { 555 + const session = await getSession(name); 556 + if (!session) { 557 + console.error(`Session "${name}" not found.`); 558 + process.exit(1); 559 + } 560 + if (session.status === "exited") { 561 + if (json) { 562 + console.log(JSON.stringify({ 563 + name: session.name, 564 + status: "exited", 565 + exitCode: session.metadata?.exitCode ?? null, 566 + exitedAt: session.metadata?.exitedAt ?? null, 567 + })); 568 + } else { 569 + const code = session.metadata?.exitCode ?? "?"; 570 + console.log(`Session "${name}" has exited (code ${code}).`); 571 + } 572 + return; 573 + } 574 + 575 + try { 576 + const stats = await queryStats(name); 577 + if (json) { 578 + console.log(JSON.stringify(stats)); 579 + } else { 580 + printStats(stats, session.metadata); 581 + } 582 + } catch (e: any) { 583 + console.error(e.message); 584 + process.exit(1); 585 + } 586 + return; 587 + } 588 + 589 + // All sessions 590 + const sessions = await listSessions(); 591 + const running = sessions.filter((s) => s.status === "running"); 592 + const exited = sessions.filter((s) => s.status === "exited"); 593 + 594 + if (running.length === 0 && (!all || exited.length === 0)) { 595 + console.log("No running sessions."); 596 + return; 597 + } 598 + 599 + // Query all running sessions in parallel 600 + const results = await Promise.all( 601 + running.map(async (s) => { 602 + try { 603 + const stats = await queryStats(s.name); 604 + return { session: s, stats, error: null as string | null }; 605 + } catch (e: any) { 606 + return { session: s, stats: null as StatsResult | null, error: e.message as string }; 607 + } 608 + }), 609 + ); 610 + 611 + if (json) { 612 + const output = [ 613 + ...results.map((r) => r.stats ?? { name: r.session.name, error: r.error }), 614 + ...(all 615 + ? exited.map((s) => ({ 616 + name: s.name, 617 + status: "exited" as const, 618 + exitCode: s.metadata?.exitCode ?? null, 619 + exitedAt: s.metadata?.exitedAt ?? null, 620 + })) 621 + : []), 622 + ]; 623 + console.log(JSON.stringify(output)); 624 + return; 625 + } 626 + 627 + for (let i = 0; i < results.length; i++) { 628 + const r = results[i]; 629 + if (r.stats) { 630 + printStats(r.stats, r.session.metadata); 631 + } else { 632 + console.log(`Session: ${r.session.name}`); 633 + console.log(` Error: ${r.error}`); 634 + } 635 + if (i < results.length - 1) console.log(""); 636 + } 637 + 638 + if (all && exited.length > 0) { 639 + if (results.length > 0) console.log(""); 640 + console.log("Exited sessions:"); 641 + for (const s of exited) { 642 + const code = s.metadata?.exitCode ?? "?"; 643 + const ago = s.metadata?.exitedAt ? timeAgo(new Date(s.metadata.exitedAt)) : "unknown"; 644 + console.log(` ${s.name} (exited with code ${code}, ${ago})`); 645 + } 646 + } 647 + } 648 + 649 + function printStats(stats: StatsResult, meta: SessionInfo["metadata"]): void { 650 + const cmd = meta 651 + ? [meta.displayCommand, ...meta.args].join(" ") 652 + : "unknown"; 653 + const cwd = meta?.cwd ? shortPath(meta.cwd) : "unknown"; 654 + 655 + console.log(`Session: ${stats.name}`); 656 + console.log(` Command: ${cmd}`); 657 + console.log(` CWD: ${cwd}`); 658 + console.log(` Uptime: ${formatUptime(stats.uptimeSeconds)}`); 659 + console.log(` Process: ${stats.process.alive ? "running" : `exited (code ${stats.process.exitCode})`}`); 660 + console.log(` Terminal: ${stats.terminal.cols}x${stats.terminal.rows}`); 661 + console.log(` Cursor: row ${stats.terminal.cursorY}, col ${stats.terminal.cursorX}`); 662 + console.log(` Scrollback: ${stats.terminal.scrollbackUsed} / ${stats.terminal.scrollbackCapacity} lines`); 663 + console.log(` Clients: ${stats.clients.total} (${stats.clients.attached} attached, ${stats.clients.readOnly} readonly)`); 664 + 665 + const modes: string[] = []; 666 + if (stats.modes.sgrMouse) modes.push("SGR mouse"); 667 + if (stats.modes.cursorHidden) modes.push("cursor hidden"); 668 + if (stats.modes.kittyKeyboard) modes.push(`kitty keyboard (flags: ${stats.modes.kittyKeyboardFlags.join(",")})`); 669 + console.log(` Modes: ${modes.length > 0 ? modes.join(", ") : "none"}`); 670 + } 671 + 672 + function formatUptime(seconds: number | null): string { 673 + if (seconds == null) return "unknown"; 674 + if (seconds < 60) return `${seconds}s`; 675 + const m = Math.floor(seconds / 60); 676 + const s = seconds % 60; 677 + if (m < 60) return `${m}m ${s}s`; 678 + const h = Math.floor(m / 60); 679 + const rm = m % 60; 680 + if (h < 24) return `${h}h ${rm}m`; 681 + const d = Math.floor(h / 24); 682 + return `${d}d ${h % 24}h`; 534 683 } 535 684 536 685 async function cmdKill(name: string): Promise<void> {
+66
src/client.ts
··· 8 8 encodeDetach, 9 9 encodePeek, 10 10 encodeResize, 11 + encodeStatus, 11 12 decodeExit, 12 13 } from "./protocol.ts"; 13 14 import { getSocketPath } from "./sessions.ts"; ··· 181 182 182 183 socket.on("close", () => { 183 184 process.exit(0); 185 + }); 186 + } 187 + 188 + export interface StatsResult { 189 + name: string; 190 + terminal: { 191 + cols: number; 192 + rows: number; 193 + cursorX: number; 194 + cursorY: number; 195 + scrollbackUsed: number; 196 + scrollbackCapacity: number; 197 + }; 198 + process: { alive: boolean; exitCode: number | null }; 199 + clients: { total: number; attached: number; readOnly: number }; 200 + modes: { 201 + sgrMouse: boolean; 202 + cursorHidden: boolean; 203 + kittyKeyboard: boolean; 204 + kittyKeyboardFlags: number[]; 205 + }; 206 + uptimeSeconds: number | null; 207 + createdAt: string | null; 208 + } 209 + 210 + /** Query live stats from a running session. */ 211 + export function queryStats(name: string, timeoutMs = 2000): Promise<StatsResult> { 212 + return new Promise((resolve, reject) => { 213 + const socketPath = getSocketPath(name); 214 + const reader = new PacketReader(); 215 + const socket = net.createConnection(socketPath); 216 + 217 + const timer = setTimeout(() => { 218 + socket.destroy(); 219 + reject(new Error(`Timeout querying stats for "${name}"`)); 220 + }, timeoutMs); 221 + 222 + socket.on("connect", () => { 223 + socket.write(encodeStatus()); 224 + }); 225 + 226 + socket.on("data", (data: Buffer) => { 227 + const packets = reader.feed(data); 228 + for (const packet of packets) { 229 + if (packet.type === MessageType.STATUS) { 230 + clearTimeout(timer); 231 + socket.destroy(); 232 + try { 233 + resolve(JSON.parse(packet.payload.toString())); 234 + } catch { 235 + reject(new Error(`Invalid stats response from "${name}"`)); 236 + } 237 + return; 238 + } 239 + } 240 + }); 241 + 242 + socket.on("error", (err: NodeJS.ErrnoException) => { 243 + clearTimeout(timer); 244 + if (err.code === "ENOENT" || err.code === "ECONNREFUSED") { 245 + reject(new Error(`Session "${name}" not found or not running.`)); 246 + } else { 247 + reject(new Error(`Connection error: ${err.message}`)); 248 + } 249 + }); 184 250 }); 185 251 } 186 252
+9
src/protocol.ts
··· 8 8 EXIT: 4, // Server → Client: process exited 9 9 SCREEN: 5, // Server → Client: screen buffer replay on attach 10 10 PEEK: 6, // Client → Server: read-only attach (no input, no resize) 11 + STATUS: 7, // Client → Server: request stats; Server → Client: JSON stats response 11 12 } as const; 12 13 13 14 export type MessageType = (typeof MessageType)[keyof typeof MessageType]; ··· 63 64 64 65 export function encodeScreen(data: string): Buffer { 65 66 return encodePacket(MessageType.SCREEN, Buffer.from(data)); 67 + } 68 + 69 + export function encodeStatus(): Buffer { 70 + return encodePacket(MessageType.STATUS, Buffer.alloc(0)); 71 + } 72 + 73 + export function encodeStatusResponse(json: string): Buffer { 74 + return encodePacket(MessageType.STATUS, Buffer.from(json)); 66 75 } 67 76 68 77 export function decodeSize(payload: Buffer): { rows: number; cols: number } {
+54 -1
src/server.ts
··· 13 13 encodeData, 14 14 encodeExit, 15 15 encodeScreen, 16 + encodeStatusResponse, 16 17 decodeSize, 17 18 } from "./protocol.ts"; 18 19 import { ··· 72 73 this.terminal = new xterm.Terminal({ 73 74 rows: options.rows, 74 75 cols: options.cols, 75 - scrollback: 1000, 76 + scrollback: 10000, 76 77 allowProposedApi: true, 77 78 }); 78 79 this.serialize = new xtermSerialize.SerializeAddon(); ··· 288 289 socket.end(); 289 290 break; 290 291 } 292 + 293 + case MessageType.STATUS: { 294 + const stats = this.collectStats(); 295 + socket.write(encodeStatusResponse(JSON.stringify(stats))); 296 + break; 297 + } 291 298 } 292 299 } 293 300 }); ··· 311 318 prefix += `\x1b[>${flags}u`; 312 319 } 313 320 return prefix; 321 + } 322 + 323 + private collectStats(): object { 324 + const buf = this.terminal.buffer.active; 325 + const meta = readMetadata(this.name); 326 + 327 + let attached = 0; 328 + let readOnly = 0; 329 + for (const c of this.clients.values()) { 330 + if (c.readonly) readOnly++; 331 + else if (c.attachSeq > 0) attached++; 332 + } 333 + 334 + const createdAt = meta?.createdAt ?? null; 335 + const uptimeSeconds = createdAt 336 + ? Math.floor((Date.now() - new Date(createdAt).getTime()) / 1000) 337 + : null; 338 + 339 + return { 340 + name: this.name, 341 + terminal: { 342 + cols: this.terminal.cols, 343 + rows: this.terminal.rows, 344 + cursorX: buf.cursorX, 345 + cursorY: buf.cursorY, 346 + scrollbackUsed: buf.length, 347 + scrollbackCapacity: this.terminal.rows + (this.terminal.options.scrollback ?? 10000), 348 + }, 349 + process: { 350 + alive: !this.exited, 351 + exitCode: this.exited ? this.exitCode : null, 352 + }, 353 + clients: { 354 + total: this.clients.size, 355 + attached, 356 + readOnly, 357 + }, 358 + modes: { 359 + sgrMouse: this.sgrMouseMode, 360 + cursorHidden: this.cursorHidden, 361 + kittyKeyboard: this.kittyKeyboardStack.length > 0, 362 + kittyKeyboardFlags: [...this.kittyKeyboardStack], 363 + }, 364 + uptimeSeconds, 365 + createdAt, 366 + }; 314 367 } 315 368 316 369 /** Resize the PTY to the smallest dimensions across all connected writable clients.
+3 -3
src/testing/session.ts
··· 71 71 const terminal = new xterm.Terminal({ 72 72 rows, 73 73 cols, 74 - scrollback: 1000, 74 + scrollback: 10000, 75 75 allowProposedApi: true, 76 76 }); 77 77 const serialize = new xtermSerialize.SerializeAddon(); ··· 127 127 const terminal = new xterm.Terminal({ 128 128 rows, 129 129 cols, 130 - scrollback: 1000, 130 + scrollback: 10000, 131 131 allowProposedApi: true, 132 132 }); 133 133 const serialize = new xtermSerialize.SerializeAddon(); ··· 168 168 const terminal = new xterm.Terminal({ 169 169 rows, 170 170 cols, 171 - scrollback: 1000, 171 + scrollback: 10000, 172 172 allowProposedApi: true, 173 173 }); 174 174 const serialize = new xtermSerialize.SerializeAddon();
+2 -2
src/tui/builders.ts
··· 500 500 const net = require("node:net") as typeof import("node:net"); 501 501 const xtermMod = require("@xterm/headless") as any; 502 502 const TerminalClass = xtermMod.default?.Terminal ?? xtermMod.Terminal; 503 - const { getSocketPath } = require("../sessions.ts") as typeof import("../sessions.ts"); 503 + const { getSocketPath } = require("../sessions.js") as typeof import("../sessions.ts"); 504 504 const { 505 505 MessageType, PacketReader, encodeAttach, encodeData, encodeResize, encodeDetach, 506 - } = require("../protocol.ts") as typeof import("../protocol.ts"); 506 + } = require("../protocol.js") as typeof import("../protocol.ts"); 507 507 508 508 const cols = opts?.cols ?? 80; 509 509 const rows = opts?.rows ?? 24;
+7 -1
src/tui/index.ts
··· 89 89 // App lifecycle 90 90 export { app, type AppConfig, type App } from "./app.ts"; 91 91 92 - // Daemon spawn 92 + // Session management 93 + export { 94 + listSessions, getSession, 95 + type SessionInfo, type SessionMetadata, 96 + } from "../sessions.ts"; 97 + 98 + // Daemon spawning 93 99 export { spawnDaemon } from "../spawn.ts";
+100
tests/integration.test.ts
··· 14 14 encodeDetach, 15 15 encodePeek, 16 16 encodeResize, 17 + encodeStatus, 17 18 decodeExit, 18 19 } from "../src/protocol.ts"; 19 20 import { ··· 1247 1248 }); 1248 1249 } 1249 1250 }); 1251 + 1252 + describe("STATUS message", () => { 1253 + it("responds with valid JSON containing all expected fields", async () => { 1254 + const name = uniqueName(); 1255 + await startServer(name, "sh", ["-c", "echo hello; sleep 30"]); 1256 + await new Promise((r) => setTimeout(r, 300)); 1257 + 1258 + const client = await connect(name); 1259 + const reader = new PacketReader(); 1260 + client.write(encodeStatus()); 1261 + 1262 + const packet = await waitForType(client, reader, MessageType.STATUS); 1263 + const stats = JSON.parse(packet.payload.toString()); 1264 + 1265 + expect(stats.name).toBe(name); 1266 + expect(stats.terminal.cols).toBe(80); 1267 + expect(stats.terminal.rows).toBe(24); 1268 + expect(stats.terminal.cursorX).toBeGreaterThanOrEqual(0); 1269 + expect(stats.terminal.cursorY).toBeGreaterThanOrEqual(0); 1270 + expect(stats.terminal.scrollbackUsed).toBeGreaterThan(0); 1271 + expect(stats.terminal.scrollbackCapacity).toBe(24 + 10000); 1272 + expect(stats.process.alive).toBe(true); 1273 + expect(stats.process.exitCode).toBeNull(); 1274 + expect(stats.clients.total).toBeGreaterThanOrEqual(1); 1275 + expect(stats.modes).toBeDefined(); 1276 + expect(stats.uptimeSeconds).toBeGreaterThanOrEqual(0); 1277 + 1278 + client.destroy(); 1279 + }); 1280 + 1281 + it("reports correct client counts", async () => { 1282 + const name = uniqueName(); 1283 + await startServer(name, "cat"); 1284 + 1285 + const c1 = await connect(name); 1286 + const r1 = new PacketReader(); 1287 + c1.write(encodeAttach(24, 80)); 1288 + await waitForType(c1, r1, MessageType.SCREEN); 1289 + 1290 + const peeker = await connect(name); 1291 + const peekReader = new PacketReader(); 1292 + peeker.write(encodePeek()); 1293 + await waitForType(peeker, peekReader, MessageType.SCREEN); 1294 + 1295 + const statsClient = await connect(name); 1296 + const statsReader = new PacketReader(); 1297 + statsClient.write(encodeStatus()); 1298 + 1299 + const packet = await waitForType(statsClient, statsReader, MessageType.STATUS); 1300 + const stats = JSON.parse(packet.payload.toString()); 1301 + 1302 + expect(stats.clients.total).toBe(3); 1303 + expect(stats.clients.attached).toBe(1); 1304 + expect(stats.clients.readOnly).toBe(1); 1305 + 1306 + c1.destroy(); 1307 + peeker.destroy(); 1308 + statsClient.destroy(); 1309 + }); 1310 + 1311 + it("reports exited process", async () => { 1312 + const name = uniqueName(); 1313 + await startServer(name, "sh", ["-c", "exit 7"]); 1314 + await new Promise((r) => setTimeout(r, 500)); 1315 + 1316 + const client = await connect(name); 1317 + const reader = new PacketReader(); 1318 + client.write(encodeStatus()); 1319 + 1320 + const packet = await waitForType(client, reader, MessageType.STATUS); 1321 + const stats = JSON.parse(packet.payload.toString()); 1322 + 1323 + expect(stats.process.alive).toBe(false); 1324 + expect(stats.process.exitCode).toBe(7); 1325 + 1326 + client.destroy(); 1327 + }); 1328 + 1329 + it("reports terminal modes", async () => { 1330 + const name = uniqueName(); 1331 + await startServer(name, "sh", [ 1332 + "-c", 1333 + "printf '\\033[?1006h'; printf '\\033[?25l'; sleep 30", 1334 + ]); 1335 + await new Promise((r) => setTimeout(r, 500)); 1336 + 1337 + const client = await connect(name); 1338 + const reader = new PacketReader(); 1339 + client.write(encodeStatus()); 1340 + 1341 + const packet = await waitForType(client, reader, MessageType.STATUS); 1342 + const stats = JSON.parse(packet.payload.toString()); 1343 + 1344 + expect(stats.modes.sgrMouse).toBe(true); 1345 + expect(stats.modes.cursorHidden).toBe(true); 1346 + 1347 + client.destroy(); 1348 + }); 1349 + });
+26
tests/protocol.test.ts
··· 9 9 encodeResize, 10 10 encodeExit, 11 11 encodeScreen, 12 + encodeStatus, 13 + encodeStatusResponse, 12 14 decodeSize, 13 15 decodeExit, 14 16 } from "../src/protocol.ts"; ··· 183 185 184 186 it("decodeExit returns -1 for empty payload", () => { 185 187 expect(decodeExit(Buffer.alloc(0))).toBe(-1); 188 + }); 189 + }); 190 + 191 + describe("STATUS", () => { 192 + it("round-trips a STATUS request (empty payload)", () => { 193 + const reader = new PacketReader(); 194 + const encoded = encodeStatus(); 195 + const packets = reader.feed(encoded); 196 + expect(packets).toHaveLength(1); 197 + expect(packets[0].type).toBe(MessageType.STATUS); 198 + expect(packets[0].payload.length).toBe(0); 199 + }); 200 + 201 + it("round-trips a STATUS response (JSON payload)", () => { 202 + const reader = new PacketReader(); 203 + const json = JSON.stringify({ name: "test", terminal: { cols: 80, rows: 24 } }); 204 + const encoded = encodeStatusResponse(json); 205 + const packets = reader.feed(encoded); 206 + expect(packets).toHaveLength(1); 207 + expect(packets[0].type).toBe(MessageType.STATUS); 208 + expect(JSON.parse(packets[0].payload.toString())).toEqual({ 209 + name: "test", 210 + terminal: { cols: 80, rows: 24 }, 211 + }); 186 212 }); 187 213 }); 188 214 });
+174
tests/stats-cli.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, execFileSync, type ChildProcess } from "node:child_process"; 7 + 8 + const __dirname = path.dirname(fileURLToPath(import.meta.url)); 9 + const nodeBin = process.execPath; 10 + const cliPath = path.join(__dirname, "..", "dist", "cli.js"); 11 + const serverModule = path.join(__dirname, "..", "dist", "server.js"); 12 + 13 + const testRoot = fs.mkdtempSync(path.join(os.tmpdir(), "pty-stats-")); 14 + afterAll(() => { 15 + fs.rmSync(testRoot, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); 16 + }); 17 + 18 + let bgPids: number[] = []; 19 + let sessionDirs: string[] = []; 20 + 21 + function makeSessionDir(): string { 22 + const dir = fs.mkdtempSync(path.join(testRoot, "d-")); 23 + sessionDirs.push(dir); 24 + return dir; 25 + } 26 + 27 + let nameCounter = 0; 28 + function uniqueName(): string { 29 + return `s${++nameCounter}-${Math.random().toString(36).slice(2, 6)}`; 30 + } 31 + 32 + async function startDaemon( 33 + sessionDir: string, 34 + name: string, 35 + command: string, 36 + args: string[] = [], 37 + ): Promise<number> { 38 + const config = JSON.stringify({ 39 + name, 40 + command, 41 + args, 42 + displayCommand: command, 43 + cwd: os.tmpdir(), 44 + rows: 24, 45 + cols: 80, 46 + }); 47 + 48 + const child = spawn(nodeBin, [serverModule], { 49 + detached: true, 50 + stdio: ["ignore", "ignore", "pipe"], 51 + env: { 52 + ...process.env, 53 + PTY_SERVER_CONFIG: config, 54 + PTY_SESSION_DIR: sessionDir, 55 + }, 56 + }); 57 + 58 + let stderr = ""; 59 + child.stderr?.on("data", (d: Buffer) => { stderr += d.toString(); }); 60 + let exitCode: number | null = null; 61 + child.on("exit", (code) => { exitCode = code; }); 62 + (child.stderr as any)?.unref?.(); 63 + child.unref(); 64 + 65 + const socketPath = path.join(sessionDir, `${name}.sock`); 66 + const start = Date.now(); 67 + while (Date.now() - start < 5000) { 68 + if (exitCode !== null) { 69 + throw new Error(`Daemon exited with code ${exitCode}. stderr:\n${stderr}`); 70 + } 71 + try { 72 + fs.statSync(socketPath); 73 + await new Promise((r) => setTimeout(r, 100)); 74 + bgPids.push(child.pid!); 75 + return child.pid!; 76 + } catch {} 77 + await new Promise((r) => setTimeout(r, 50)); 78 + } 79 + throw new Error(`Timeout waiting for daemon socket: ${socketPath}`); 80 + } 81 + 82 + function runStats(sessionDir: string, ...args: string[]): string { 83 + return execFileSync(nodeBin, [cliPath, "stats", ...args], { 84 + env: { ...process.env, PTY_SESSION_DIR: sessionDir }, 85 + encoding: "utf-8", 86 + timeout: 10000, 87 + }); 88 + } 89 + 90 + afterEach(() => { 91 + for (const pid of bgPids) { 92 + try { process.kill(pid, "SIGTERM"); } catch {} 93 + } 94 + bgPids = []; 95 + for (const dir of sessionDirs) { 96 + try { 97 + const entries = fs.readdirSync(dir); 98 + for (const e of entries) { 99 + try { fs.unlinkSync(path.join(dir, e)); } catch {} 100 + } 101 + } catch {} 102 + } 103 + sessionDirs = []; 104 + }); 105 + 106 + describe("pty stats CLI", () => { 107 + it("prints stats for a named session", async () => { 108 + const dir = makeSessionDir(); 109 + const name = uniqueName(); 110 + await startDaemon(dir, name, "cat"); 111 + 112 + const output = runStats(dir, name); 113 + 114 + expect(output).toContain(`Session: ${name}`); 115 + expect(output).toContain("Terminal:"); 116 + expect(output).toContain("Scrollback:"); 117 + expect(output).toContain("Clients:"); 118 + expect(output).toContain("Process:"); 119 + expect(output).toContain("Modes:"); 120 + expect(output).toContain("running"); 121 + }, 15000); 122 + 123 + it("returns valid JSON with --json flag", async () => { 124 + const dir = makeSessionDir(); 125 + const name = uniqueName(); 126 + await startDaemon(dir, name, "cat"); 127 + 128 + const output = runStats(dir, "--json", name); 129 + const stats = JSON.parse(output); 130 + 131 + expect(stats.name).toBe(name); 132 + expect(stats.terminal).toBeDefined(); 133 + expect(stats.terminal.cols).toBe(80); 134 + expect(stats.terminal.rows).toBe(24); 135 + expect(stats.terminal.scrollbackCapacity).toBe(24 + 10000); 136 + expect(stats.process.alive).toBe(true); 137 + expect(stats.clients).toBeDefined(); 138 + expect(stats.modes).toBeDefined(); 139 + }, 15000); 140 + 141 + it("queries all running sessions when no name given", async () => { 142 + const dir = makeSessionDir(); 143 + const name1 = uniqueName(); 144 + const name2 = uniqueName(); 145 + await startDaemon(dir, name1, "cat"); 146 + await startDaemon(dir, name2, "cat"); 147 + 148 + const output = runStats(dir); 149 + 150 + expect(output).toContain(`Session: ${name1}`); 151 + expect(output).toContain(`Session: ${name2}`); 152 + }, 15000); 153 + 154 + it("exits with error for nonexistent session", async () => { 155 + const dir = makeSessionDir(); 156 + 157 + try { 158 + runStats(dir, "nonexistent"); 159 + expect.fail("should have thrown"); 160 + } catch (err: any) { 161 + expect(err.status).not.toBe(0); 162 + } 163 + }, 15000); 164 + 165 + it("shows exited message for dead session", async () => { 166 + const dir = makeSessionDir(); 167 + const name = uniqueName(); 168 + await startDaemon(dir, name, "true"); // exits immediately 169 + await new Promise((r) => setTimeout(r, 1000)); // wait for exit 170 + 171 + const output = runStats(dir, name); 172 + expect(output).toContain("exited"); 173 + }, 15000); 174 + });