This repository has no description
0

Configure Feed

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

Prevent session nesting and add resource usage to stats

Detect when pty run is invoked inside an existing session via
PTY_SESSION env var and exec the command directly (-d bypasses).
Add CPU, memory, and PID reporting to pty stats for both the
child process and daemon.

Nathan Herald (Apr 6, 2026, 3:35 PM +0200) 711d1506 e3153025

+341 -2
+7
CHANGELOG.md
··· 1 1 # Changelog 2 2 3 + ## Unreleased 4 + 5 + - 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 + - Set `PTY_SESSION` env var in child processes so they can detect they're inside a pty session 7 + - Add CPU and memory usage to `pty stats` (child process and daemon, via `ps`) 8 + - Add process PIDs to `pty stats` output 9 + 3 10 ## 0.4.1 4 11 5 12 - Add `pty stats` command for live session metrics (terminal size, scrollback, clients, modes, uptime)
+26 -1
src/cli.ts
··· 144 144 process.exit(1); 145 145 } 146 146 147 + // Nesting prevention: if inside a pty session and not detaching, exec directly 148 + if (process.env.PTY_SESSION && !detach) { 149 + console.error( 150 + `Already inside pty session "${process.env.PTY_SESSION}", running directly.` 151 + ); 152 + const result = spawnSync(cmd, cmdArgs, { 153 + stdio: "inherit", 154 + env: process.env, 155 + }); 156 + process.exit(result.status ?? 1); 157 + } 158 + 147 159 // Auto-generate name if not provided 148 160 if (!name) { 149 161 const sessions = await listSessions(); ··· 693 705 console.log(` Command: ${cmd}`); 694 706 console.log(` CWD: ${cwd}`); 695 707 console.log(` Uptime: ${formatUptime(stats.uptimeSeconds)}`); 696 - console.log(` Process: ${stats.process.alive ? "running" : `exited (code ${stats.process.exitCode})`}`); 708 + console.log(` Process: ${stats.process.alive ? "running" : `exited (code ${stats.process.exitCode})`}${stats.process.pid ? ` (pid ${stats.process.pid})` : ""}`); 709 + if (stats.process.resources) { 710 + console.log(` CPU: ${stats.process.resources.cpuPercent.toFixed(1)}%`); 711 + console.log(` Memory: ${formatMemory(stats.process.resources.rssKb)}`); 712 + } 713 + console.log(` Daemon: pid ${stats.daemon.pid}${stats.daemon.resources ? `, ${formatMemory(stats.daemon.resources.rssKb)}` : ""}`); 697 714 console.log(` Terminal: ${stats.terminal.cols}x${stats.terminal.rows}`); 698 715 console.log(` Cursor: row ${stats.terminal.cursorY}, col ${stats.terminal.cursorX}`); 699 716 console.log(` Scrollback: ${stats.terminal.scrollbackUsed} / ${stats.terminal.scrollbackCapacity} lines`); ··· 704 721 if (stats.modes.cursorHidden) modes.push("cursor hidden"); 705 722 if (stats.modes.kittyKeyboard) modes.push(`kitty keyboard (flags: ${stats.modes.kittyKeyboardFlags.join(",")})`); 706 723 console.log(` Modes: ${modes.length > 0 ? modes.join(", ") : "none"}`); 724 + } 725 + 726 + function formatMemory(rssKb: number): string { 727 + if (rssKb < 1024) return `${rssKb} KB`; 728 + const mb = rssKb / 1024; 729 + if (mb < 1024) return `${mb.toFixed(1)} MB`; 730 + const gb = mb / 1024; 731 + return `${gb.toFixed(2)} GB`; 707 732 } 708 733 709 734 function formatUptime(seconds: number | null): string {
+15 -1
src/client.ts
··· 185 185 }); 186 186 } 187 187 188 + export interface ProcessResources { 189 + rssKb: number; 190 + cpuPercent: number; 191 + } 192 + 188 193 export interface StatsResult { 189 194 name: string; 190 195 terminal: { ··· 195 200 scrollbackUsed: number; 196 201 scrollbackCapacity: number; 197 202 }; 198 - process: { alive: boolean; exitCode: number | null }; 203 + process: { 204 + alive: boolean; 205 + exitCode: number | null; 206 + pid: number | null; 207 + resources: ProcessResources | null; 208 + }; 209 + daemon: { 210 + pid: number; 211 + resources: ProcessResources | null; 212 + }; 199 213 clients: { total: number; attached: number; readOnly: number }; 200 214 modes: { 201 215 sgrMouse: boolean;
+34
src/server.ts
··· 1 1 import * as net from "node:net"; 2 2 import * as fs from "node:fs"; 3 + import { execFileSync } from "node:child_process"; 3 4 import * as pty from "node-pty"; 4 5 // @xterm packages are CJS-only. Named imports fail under Node's native ESM 5 6 // loader (Node v24+), so we use default imports + separate type imports. ··· 48 49 } 49 50 50 51 const LAST_LINES_COUNT = 20; 52 + 53 + export interface ProcessResources { 54 + rssKb: number; // Resident set size in KB 55 + cpuPercent: number; // CPU usage percentage 56 + } 57 + 58 + /** Query CPU and memory usage for a process via ps. Returns null on failure. */ 59 + function queryProcessResources(pid: number): ProcessResources | null { 60 + try { 61 + const output = execFileSync("ps", ["-o", "rss=,pcpu=", "-p", String(pid)], { 62 + encoding: "utf-8", 63 + timeout: 1000, 64 + }).trim(); 65 + const parts = output.split(/\s+/); 66 + if (parts.length < 2) return null; 67 + return { 68 + rssKb: parseInt(parts[0], 10), 69 + cpuPercent: parseFloat(parts[1]), 70 + }; 71 + } catch { 72 + return null; 73 + } 74 + } 51 75 52 76 export class PtyServer { 53 77 private terminal: Terminal; ··· 124 148 // `exec "$@"` replaces the shell with the actual process. 125 149 const childEnv = { ...process.env }; 126 150 delete childEnv.PTY_SERVER_CONFIG; 151 + childEnv.PTY_SESSION = options.name; 127 152 try { 128 153 this.ptyProcess = pty.spawn( 129 154 "/bin/sh", ··· 337 362 ? Math.floor((Date.now() - new Date(createdAt).getTime()) / 1000) 338 363 : null; 339 364 365 + const childPid = this.exited ? null : this.ptyProcess.pid; 366 + const daemonPid = process.pid; 367 + 340 368 return { 341 369 name: this.name, 342 370 terminal: { ··· 350 378 process: { 351 379 alive: !this.exited, 352 380 exitCode: this.exited ? this.exitCode : null, 381 + pid: childPid, 382 + resources: childPid ? queryProcessResources(childPid) : null, 383 + }, 384 + daemon: { 385 + pid: daemonPid, 386 + resources: queryProcessResources(daemonPid), 353 387 }, 354 388 clients: { 355 389 total: attached + readOnly,
+214
tests/nesting.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, execFileSync } 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-nesting-")); 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 `nest${++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 runCli(sessionDir: string, ...args: string[]): string { 83 + return execFileSync(nodeBin, [cliPath, ...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 nesting prevention", () => { 107 + it("sets PTY_SESSION in child process environment", async () => { 108 + const dir = makeSessionDir(); 109 + const name = uniqueName(); 110 + // Use cat to keep the process alive after printing the env var 111 + await startDaemon(dir, name, "sh", ["-c", "echo PTY_SESSION=$PTY_SESSION; exec cat"]); 112 + 113 + // Wait for the echo to produce output 114 + await new Promise((r) => setTimeout(r, 500)); 115 + 116 + const output = runCli(dir, "peek", "--plain", name); 117 + expect(output).toContain(`PTY_SESSION=${name}`); 118 + }, 15000); 119 + 120 + it("detects nesting and runs command directly", () => { 121 + const dir = makeSessionDir(); 122 + const result = spawnSync(nodeBin, [cliPath, "run", "--", "echo", "hello"], { 123 + env: { ...process.env, PTY_SESSION_DIR: dir, PTY_SESSION: "outer-session" }, 124 + encoding: "utf-8", 125 + timeout: 10000, 126 + }); 127 + 128 + expect(result.stdout).toContain("hello"); 129 + expect(result.stderr).toContain("Already inside pty session"); 130 + expect(result.stderr).toContain("outer-session"); 131 + expect(result.status).toBe(0); 132 + 133 + // No session should have been created 134 + const sessions = runCli(dir, "list", "--json"); 135 + expect(JSON.parse(sessions)).toEqual([]); 136 + }, 15000); 137 + 138 + it("detects nesting with -a flag (wrap script path)", () => { 139 + const dir = makeSessionDir(); 140 + const result = spawnSync(nodeBin, [cliPath, "run", "-a", "--", "echo", "wrapped"], { 141 + env: { ...process.env, PTY_SESSION_DIR: dir, PTY_SESSION: "outer-session" }, 142 + encoding: "utf-8", 143 + timeout: 10000, 144 + }); 145 + 146 + expect(result.stdout).toContain("wrapped"); 147 + expect(result.stderr).toContain("Already inside pty session"); 148 + expect(result.status).toBe(0); 149 + 150 + // No session should have been created 151 + const sessions = runCli(dir, "list", "--json"); 152 + expect(JSON.parse(sessions)).toEqual([]); 153 + }, 15000); 154 + 155 + it("-d flag bypasses nesting check", async () => { 156 + const dir = makeSessionDir(); 157 + const name = uniqueName(); 158 + const result = spawnSync(nodeBin, [cliPath, "run", "-d", "--name", name, "--", "cat"], { 159 + env: { ...process.env, PTY_SESSION_DIR: dir, PTY_SESSION: "outer-session" }, 160 + encoding: "utf-8", 161 + timeout: 10000, 162 + }); 163 + 164 + expect(result.status).toBe(0); 165 + expect(result.stderr).not.toContain("Already inside pty session"); 166 + 167 + // Session should have been created 168 + const sessions = JSON.parse(runCli(dir, "list", "--json")); 169 + const found = sessions.find((s: any) => s.name === name); 170 + expect(found).toBeDefined(); 171 + expect(found.status).toBe("running"); 172 + 173 + // Clean up the daemon 174 + try { process.kill(found.pid, "SIGTERM"); } catch {} 175 + }, 15000); 176 + 177 + it("propagates exit code from nested command", () => { 178 + const dir = makeSessionDir(); 179 + const result = spawnSync(nodeBin, [cliPath, "run", "--", "sh", "-c", "exit 42"], { 180 + env: { ...process.env, PTY_SESSION_DIR: dir, PTY_SESSION: "outer-session" }, 181 + encoding: "utf-8", 182 + timeout: 10000, 183 + }); 184 + 185 + expect(result.status).toBe(42); 186 + expect(result.stderr).toContain("Already inside pty session"); 187 + }, 15000); 188 + 189 + it("does not check for nesting when PTY_SESSION is not set", async () => { 190 + const dir = makeSessionDir(); 191 + const name = uniqueName(); 192 + 193 + // Remove PTY_SESSION from env explicitly 194 + const env = { ...process.env, PTY_SESSION_DIR: dir }; 195 + delete env.PTY_SESSION; 196 + 197 + const result = spawnSync(nodeBin, [cliPath, "run", "-d", "--name", name, "--", "cat"], { 198 + env, 199 + encoding: "utf-8", 200 + timeout: 10000, 201 + }); 202 + 203 + expect(result.status).toBe(0); 204 + expect(result.stderr).not.toContain("Already inside pty session"); 205 + 206 + // Session should have been created normally 207 + const sessions = JSON.parse(runCli(dir, "list", "--json")); 208 + const found = sessions.find((s: any) => s.name === name); 209 + expect(found).toBeDefined(); 210 + 211 + // Clean up the daemon 212 + try { process.kill(found.pid, "SIGTERM"); } catch {} 213 + }, 15000); 214 + });
+45
tests/stats-cli.test.ts
··· 118 118 expect(output).toContain("Process:"); 119 119 expect(output).toContain("Modes:"); 120 120 expect(output).toContain("running"); 121 + expect(output).toContain("CPU:"); 122 + expect(output).toContain("Memory:"); 123 + expect(output).toContain("Daemon:"); 121 124 }, 15000); 122 125 123 126 it("returns valid JSON with --json flag", async () => { ··· 134 137 expect(stats.terminal.rows).toBe(24); 135 138 expect(stats.terminal.scrollbackCapacity).toBe(24 + 10000); 136 139 expect(stats.process.alive).toBe(true); 140 + expect(stats.process.pid).toBeTypeOf("number"); 141 + expect(stats.process.resources).toBeDefined(); 142 + expect(stats.process.resources.rssKb).toBeTypeOf("number"); 143 + expect(stats.process.resources.cpuPercent).toBeTypeOf("number"); 144 + expect(stats.daemon).toBeDefined(); 145 + expect(stats.daemon.pid).toBeTypeOf("number"); 146 + expect(stats.daemon.resources).toBeDefined(); 147 + expect(stats.daemon.resources.rssKb).toBeTypeOf("number"); 137 148 expect(stats.clients).toBeDefined(); 138 149 expect(stats.modes).toBeDefined(); 139 150 }, 15000); ··· 170 181 171 182 const output = runStats(dir, name); 172 183 expect(output).toContain("exited"); 184 + }, 15000); 185 + 186 + it("reports resource usage with reasonable values", async () => { 187 + const dir = makeSessionDir(); 188 + const name = uniqueName(); 189 + await startDaemon(dir, name, "cat"); 190 + 191 + const output = runStats(dir, "--json", name); 192 + const stats = JSON.parse(output); 193 + 194 + // Child process resources 195 + expect(stats.process.resources.rssKb).toBeGreaterThan(0); 196 + expect(stats.process.resources.cpuPercent).toBeGreaterThanOrEqual(0); 197 + 198 + // Daemon resources 199 + expect(stats.daemon.resources.rssKb).toBeGreaterThan(0); 200 + expect(stats.daemon.resources.cpuPercent).toBeGreaterThanOrEqual(0); 201 + 202 + // PIDs should be positive integers 203 + expect(stats.process.pid).toBeGreaterThan(0); 204 + expect(stats.daemon.pid).toBeGreaterThan(0); 205 + expect(stats.process.pid).not.toBe(stats.daemon.pid); 206 + }, 15000); 207 + 208 + it("does not show CPU/Memory for exited sessions", async () => { 209 + const dir = makeSessionDir(); 210 + const name = uniqueName(); 211 + await startDaemon(dir, name, "true"); // exits immediately 212 + await new Promise((r) => setTimeout(r, 1000)); // wait for exit 213 + 214 + const output = runStats(dir, name); 215 + expect(output).toContain("exited"); 216 + expect(output).not.toContain("CPU:"); 217 + expect(output).not.toContain("Memory:"); 173 218 }, 15000); 174 219 });