Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 52 additions & 35 deletions AGENTS.md

Large diffs are not rendered by default.

28 changes: 28 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,9 +107,37 @@ interface IPty {
resume(): void;
close(): void;
waitFor(pattern: string, options?: { timeout?: number }): Promise<string>;
stats(): IPtyStats | null; // OS-level snapshot (cwd, memory, CPU time)
}
```

### `pty.stats()`

Snapshot OS-level process info aggregated across the PTY's foreground job. `rssBytes`, `cpuUser`, and `cpuSys` are **totals** summed over the leader and every tracked child. `count` is how many processes were rolled into the totals. `children[]` lists each non-leader process (`{pid, name, rssBytes, cpuUser, cpuSys}`) so you can see the breakdown.

On Unix, aggregation targets the PTY's foreground process group (same target as `pty.process`), so the numbers follow whatever the user is currently running — workers, subshells, build fan-outs. On Windows, the unit is the shell process's transitive descendant tree (ConPTY has no pgrp concept).

```ts
const pty = spawn("/bin/bash");
// …user types `cd /tmp && cargo build`…
const s = pty.stats();
// {
// pid: 4821, // leader (pgrp on Unix, shell on Windows)
// cwd: "/tmp", // leader's cwd; null on Windows
// rssBytes: 2_147_483_648, // total across leader + children
// cpuUser: 8_430_000, // microseconds
// cpuSys: 1_250_000,
// count: 17, // leader + 16 workers
// children: [
// { pid: 4822, name: "cargo", rssBytes: 128_000_000, cpuUser: 500_000, cpuSys: 80_000 },
// { pid: 4823, name: "rustc", rssBytes: 512_000_000, cpuUser: 2_000_000, cpuSys: 300_000 },
// // …14 more…
// ],
// }
```

Returns `null` when stats can't be read (process exited, PTY closed, or running in pipe fallback on non-Linux). Polling is on-demand — no background thread, no cost when unused.

### `pty.waitFor(pattern, options?)`

Wait until the PTY output contains the given string. Returns all output collected so far. Useful for AI agents that need to read prompts before responding.
Expand Down
22 changes: 22 additions & 0 deletions src/napi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,24 @@ import { createRequire } from "node:module";
import { arch, platform } from "node:os";
import { fileURLToPath } from "node:url";

export interface INativeChildStats {
pid: number;
name: string;
rssBytes: number;
cpuUser: number;
cpuSys: number;
}

export interface INativeStats {
pid: number;
cwd: string | null;
rssBytes: number;
cpuUser: number;
cpuSys: number;
count: number;
children: INativeChildStats[];
}

export interface INativeUnix {
fork(
file: string,
Expand All @@ -21,6 +39,8 @@ export interface INativeUnix {
resize(fd: number, cols: number, rows: number, xPixel?: number, yPixel?: number): void;

process(fd: number): string | undefined;

stats(fd: number): INativeStats | undefined;
}

export interface INativeWindows {
Expand All @@ -42,6 +62,8 @@ export interface INativeWindows {
kill(handle: object): void;

close(handle: object): void;

stats(handle: object): INativeStats | undefined;
}

export type INative = INativeUnix | INativeWindows;
Expand Down
39 changes: 39 additions & 0 deletions src/pipe.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -531,6 +531,45 @@ describeUnix("PipePty: process name", () => {
});
});

const isLinux = platform() === "linux";
const describeLinux = isLinux ? describe : describe.skip;

describeLinux("PipePty: stats (linux fallback)", () => {
it("should report pid, cwd, rss, and cpu times", async () => {
const pty = new PipePty("/bin/cat", [], { cwd: "/tmp" });

// Stats are read on-demand via /proc/<pid>/{cwd,stat} — give cat a moment to start.
await new Promise((r) => setTimeout(r, 50));

const stats = pty.stats();
expect(stats).not.toBeNull();
expect(stats!.pid).toBe(pty.pid);
expect(stats!.cwd).toBe("/tmp");
expect(stats!.rssBytes).toBeGreaterThan(0);
expect(stats!.cpuUser).toBeGreaterThanOrEqual(0);
expect(stats!.cpuSys).toBeGreaterThanOrEqual(0);

pty.kill("SIGTERM");
});

it("should return null after close", () => {
const pty = new PipePty("/bin/cat", []);
pty.close();
expect(pty.stats()).toBeNull();
});
});

const describeNonLinux = isLinux ? describe.skip : describe;

describeNonLinux("PipePty: stats (non-linux fallback)", () => {
it("should return null on non-linux platforms", () => {
const cmd = isWindows ? ["/c", "ping -n 2 127.0.0.1"] : ["-c", "sleep 1"];
const pty = new PipePty(shell, cmd);
expect(pty.stats()).toBeNull();
pty.close();
});
});

describe("PipePty: error handling", () => {
it("should handle spawn failure gracefully", async () => {
const pty = new PipePty("/nonexistent/binary", []);
Expand Down
3 changes: 2 additions & 1 deletion src/pty/_base.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { IDisposable, IEvent, IPty, IPtyOptions } from "./types.ts";
import type { IDisposable, IEvent, IPty, IPtyOptions, IPtyStats } from "./types.ts";
import { Terminal } from "../terminal.ts";
import type { TerminalOptions } from "../terminal.ts";

Expand Down Expand Up @@ -143,6 +143,7 @@ export abstract class BasePty implements IPty {
abstract pause(): void;
abstract resume(): void;
abstract close(): void;
abstract stats(): IPtyStats | null;
}

export function buildEnvPairs(
Expand Down
153 changes: 152 additions & 1 deletion src/pty/pipe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,9 @@
* - ^Z (SIGTSTP) is not translated — no controlling terminal to resume from
*/
import { spawn as cpSpawn, type ChildProcess } from "node:child_process";
import * as fs from "node:fs";
import * as os from "node:os";
import type { IPtyOptions } from "./types.ts";
import type { IPtyOptions, IPtyStats } from "./types.ts";
import { BasePty, DEFAULT_COLS, DEFAULT_ROWS } from "./_base.ts";

// Signal character → signal name mapping
Expand Down Expand Up @@ -179,6 +180,14 @@ export class PipePty extends BasePty {
return this._file;
}

stats(): IPtyStats | null {
if (this._closed || this.pid <= 0) return null;
// Linux-only: read from /proc/<pid>/{cwd,stat}. Other platforms return null
// in fallback mode — no syscalls we can reach from pure TS.
if (os.platform() !== "linux") return null;
return readLinuxStats(this.pid);
}

write(data: string): void {
if (this._closed) return;

Expand Down Expand Up @@ -423,3 +432,145 @@ export class PipePty extends BasePty {
}
}
}

// USER_HZ is hard-coded to 100 on every Linux kernel — native path does the same
// (see pty_linux.zig) to avoid sysconf's SC-constant mismatch on Android/Bionic.
const CLK_TCK = 100;

let _pageSize: number | null = null;
function getPageSize(): number {
if (_pageSize !== null) return _pageSize;
// Parse AT_PAGESZ from /proc/self/auxv — needed for ARM64 Linux with 16K pages.
// Entries are stored in the target's native endianness.
try {
const auxv = fs.readFileSync("/proc/self/auxv");
const is64 = ["arm64", "x64", "ppc64", "s390x", "mips64el", "riscv64", "loong64"].includes(
process.arch,
);
const isLE = os.endianness() === "LE";
const wordSize = is64 ? 8 : 4;
const AT_PAGESZ = 6;
const AT_NULL = 0;
const readWord = (off: number): number => {
if (is64) {
const big = isLE ? auxv.readBigUInt64LE(off) : auxv.readBigUInt64BE(off);
return Number(big);
}
return isLE ? auxv.readUInt32LE(off) : auxv.readUInt32BE(off);
};
for (let i = 0; i + wordSize * 2 <= auxv.length; i += wordSize * 2) {
const key = readWord(i);
if (key === AT_NULL) break;
if (key === AT_PAGESZ) {
_pageSize = readWord(i + wordSize);
return _pageSize;
}
}
} catch {}
_pageSize = 4096;
return _pageSize;
}

/** Parsed /proc/<pid>/stat row used for aggregation. */
interface ProcStatRow {
comm: string;
pgrp: number;
utimeTicks: number;
stimeTicks: number;
rssPages: number;
}

function parseProcStat(raw: string): ProcStatRow | null {
const firstParen = raw.indexOf("(");
const lastParen = raw.lastIndexOf(")");
if (firstParen < 0 || lastParen < 0 || lastParen <= firstParen || lastParen + 2 >= raw.length) {
return null;
}
const comm = raw.slice(firstParen + 1, lastParen);
const fields = raw.slice(lastParen + 2).split(" ");
// Indices after last ')': 2=pgrp, 11=utime, 12=stime, 21=rss_pages
const pgrp = Number(fields[2] ?? 0);
if (!Number.isFinite(pgrp)) return null;
return {
comm,
pgrp,
utimeTicks: Number(fields[11] ?? 0),
stimeTicks: Number(fields[12] ?? 0),
rssPages: Number(fields[21] ?? 0),
};
}

function readLinuxStats(pid: number): IPtyStats | null {
const pageSize = getPageSize();
// For shells spawned with `detached: true`, setsid() makes the leader its own
// pgrp, so walking /proc by pgrp aggregates the full job. For non-shell commands
// (no detached), the child inherits the parent's pgrp, so we always include the
// leader pid itself and additionally pick up siblings whose pgrp matches.
let leaderCwd: string | null = null;
try {
leaderCwd = fs.readlinkSync(`/proc/${pid}/cwd`);
} catch {}

let procDir: string[];
try {
procDir = fs.readdirSync("/proc");
} catch {
return null;
}

let totalRss = 0;
let totalUser = 0;
let totalSys = 0;
let count = 0;
const children: {
pid: number;
name: string;
rssBytes: number;
cpuUser: number;
cpuSys: number;
}[] = [];

for (const entry of procDir) {
const entryPid = Number(entry);
if (!Number.isInteger(entryPid) || entryPid <= 0) continue;
let raw: string;
try {
raw = fs.readFileSync(`/proc/${entryPid}/stat`, "utf8");
} catch {
continue;
}
const row = parseProcStat(raw);
if (!row) continue;
if (entryPid !== pid && row.pgrp !== pid) continue;

const rssBytes = row.rssPages * pageSize;
const cpuUser = Math.floor((row.utimeTicks * 1_000_000) / CLK_TCK);
const cpuSys = Math.floor((row.stimeTicks * 1_000_000) / CLK_TCK);
totalRss += rssBytes;
totalUser += cpuUser;
totalSys += cpuSys;
count += 1;

if (entryPid !== pid) {
children.push({
pid: entryPid,
name: row.comm.slice(0, 31),
rssBytes,
cpuUser,
cpuSys,
});
}
}

if (count === 0) return null;

return {
pid,
cwd: leaderCwd,
rssBytes: totalRss,
cpuUser: totalUser,
cpuSys: totalSys,
count,
children,
};
}
36 changes: 36 additions & 0 deletions src/pty/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,40 @@ export interface IDisposable {
dispose(): void;
}

export interface IPtyChildStats {
/** Process ID. */
pid: number;
/** Short executable / command name (truncated to ~15 chars on Unix, up to 31 on Windows). */
name: string;
/** Resident set size (physical memory) in bytes. */
rssBytes: number;
/** Accumulated user-mode CPU time in microseconds. */
cpuUser: number;
/** Accumulated system-mode CPU time in microseconds. */
cpuSys: number;
}

export interface IPtyStats {
/** Leader PID. On Unix this is the foreground process group of the PTY; on Windows it's the spawned shell process. */
pid: number;
/** Leader's current working directory. `null` when unavailable (always on Windows, or when the process has exited). */
cwd: string | null;
/** Total resident set size (physical memory) in bytes, aggregated across leader + children. */
rssBytes: number;
/** Total accumulated user-mode CPU time in microseconds, aggregated across leader + children. */
cpuUser: number;
/** Total accumulated system-mode CPU time in microseconds, aggregated across leader + children. */
cpuSys: number;
/** Total number of processes aggregated (leader + children). Always `>= 1`. */
count: number;
/**
* Non-leader processes aggregated into the totals.
* - Unix: every other process in the foreground process group.
* - Windows: every transitive descendant of the shell process.
*/
children: IPtyChildStats[];
}

export interface IPty {
/** Process ID of the spawned process. */
pid: number;
Expand Down Expand Up @@ -43,6 +77,8 @@ export interface IPty {
close(): void;
/** Wait until the output contains the given string. Resolves with all output collected so far. */
waitFor(pattern: string, options?: { timeout?: number }): Promise<string>;
/** Snapshot OS-level stats (cwd, memory, CPU time) for the PTY's foreground process. Returns null when unavailable. */
stats(): IPtyStats | null;
}

export interface IPtyOpenOptions {
Expand Down
11 changes: 10 additions & 1 deletion src/pty/unix.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { native as _native } from "../napi.ts";
import type { INativeUnix } from "../napi.ts";

const native = _native as INativeUnix;
import type { IPtyOptions } from "./types.ts";
import type { IPtyOptions, IPtyStats } from "./types.ts";
import { BasePty, DEFAULT_COLS, DEFAULT_ROWS, buildEnvPairs } from "./_base.ts";
import { WriteQueue } from "./_writeQueue.ts";

Expand Down Expand Up @@ -94,6 +94,15 @@ export class UnixPty extends BasePty {
}
}

stats(): IPtyStats | null {
if (this._closed) return null;
try {
return native.stats(this._fd) ?? null;
} catch {
return null;
}
}

write(data: string): void {
if (this._closed) return;
this._wq.enqueue(data, this._encoding);
Expand Down
Loading
Loading