@phren/cli
Version:
Knowledge layer for AI agents - CLI, MCP server, and data layer
462 lines (461 loc) • 25.2 kB
JavaScript
import { execFile } from "node:child_process";
import { readdir, readFile, statfs } from "node:fs/promises";
import { cpus, loadavg, totalmem, uptime } from "node:os";
import path from "node:path";
import { promisify } from "node:util";
import { homeDir } from "../home-paths.js";
import { recentServers, sharedSnapshot } from "./herdr.js";
import { JobRegistry, jobPgids, paneKey } from "./job-registry.js";
import { intervalFromEnv } from "./limits.js";
import { objects } from "./protocol.js";
const exec = promisify(execFile);
/**
* This computer's live resources for `GET /v1/resources`, the overview
* stream's `resources` frames and the memory-free `phren computers` surface:
* load against cores, memory, free disk on the home volume, battery, uptime
* and the heavy jobs (simulators, emulators, xcodebuild, Gradle, agent
* workers) with the pane that started them where one did; any other program
* holding half a core or more across its processes shows as `busy`. macOS reads
* sysctl, pmset and ps; Linux reads /proc, /sys and ps. Nothing here needs a
* permission the Hook does not already have.
*/
export const RESOURCES_MAX_AGE_MS = intervalFromEnv("PHREN_RESOURCES_MAX_AGE_MS", 10_000, 1_000, 60_000);
/** Below this much free disk, or above twice the cores in load, a computer is stressed. */
export const LOW_DISK_BYTES = 10 * 1024 ** 3;
const GB = 1024 ** 3;
/** `system_profiler SPHardwareDataType -json` → model and chip. Intel Macs name
* the processor in `cpu_type` instead of `chip_type`. */
export function parseMacHardware(json) {
const item = JSON.parse(json).SPHardwareDataType?.[0] ?? {};
const text = (value) => typeof value === "string" && value.trim() ? value.trim().slice(0, 80) : undefined;
const model = text(item.machine_name), chip = text(item.chip_type) ?? text(item.cpu_type);
return { ...(model ? { model } : {}), ...(chip ? { chip } : {}) };
}
/** DMI vendor and product → "Dell XPS 13 9310", "Lenovo ThinkPad X1 Carbon";
* placeholder strings firmware leaves in are dropped. */
export function linuxModel(vendor, product, version) {
const clean = (value) => {
const text = value?.replace(/\s+/g, " ").trim();
return text && !/^(to be filled|default string|system (product name|manufacturer)|not specified|none|o\.e\.m\.?|unknown)/i.test(text) ? text : undefined;
};
const brand = clean(vendor)?.replace(/,?\s+(Inc\.?|Corporation|Corp\.?|Co\.,? Ltd\.?|Ltd\.?|GmbH|Technology)$/i, "");
// Lenovo puts the marketing name in product_version and a part number in product_name.
const name = brand === "LENOVO" || brand === "Lenovo" ? clean(version) ?? clean(product) : clean(product);
if (!name)
return brand;
const label = brand && !name.toLowerCase().startsWith(brand.toLowerCase()) ? `${brand === "LENOVO" ? "Lenovo" : brand} ${name}` : name;
return label.slice(0, 80);
}
/** /proc/cpuinfo's first `model name`, without the (R)/(TM) marks, "CPU" and clock. */
export function linuxChip(cpuinfo) {
const raw = /^model name\s*:\s*(.+)$/m.exec(cpuinfo)?.[1];
const chip = raw?.replace(/\((R|TM|tm|r)\)/g, "").replace(/\s+@\s+.*$/, "").replace(/\bCPU\b/g, "")
.replace(/\b\d+-Core Processor\b/i, "").replace(/\s+/g, " ").trim();
return chip ? chip.slice(0, 80) : undefined;
}
let hardwareRead;
/** Read once; the answer can't change while the Hook runs. */
export function computerHardware(platform = process.platform) {
hardwareRead ??= (async () => {
const base = { memoryBytes: totalmem(), cores: cpus().length || 1 };
if (platform === "darwin") {
// system_profiler takes about half a second, more under load.
const found = await exec("/usr/sbin/system_profiler", ["SPHardwareDataType", "-json"], { timeout: 20_000, maxBuffer: 1024 * 1024 })
.then(result => parseMacHardware(result.stdout)).catch(() => undefined);
// A failed read is tried again on the next collection rather than kept.
if (!found)
hardwareRead = undefined;
return { ...found, ...base };
}
if (platform === "linux") {
const dmi = (name) => readFile(`/sys/devices/virtual/dmi/id/${name}`, "utf8").catch(() => undefined);
const [vendor, product, version, cpuinfo] = await Promise.all([dmi("sys_vendor"), dmi("product_name"), dmi("product_version"),
readFile("/proc/cpuinfo", "utf8").catch(() => "")]);
const model = linuxModel(vendor, product, version), chip = linuxChip(cpuinfo);
return { ...(model ? { model } : {}), ...(chip ? { chip } : {}), ...base };
}
return base;
})();
return hardwareRead;
}
/** `ps -axo pid=,ppid=,pgid=,pcpu=,rss=,comm=` paired with the `args=` listing by
* pid. The older five-column stats (no pgid) still parse, with pgid 0. */
export function parsePs(stats, args) {
const argv = new Map();
for (const line of args.split("\n")) {
const match = /^\s*(\d+)\s+(.*)$/.exec(line);
if (match)
argv.set(Number(match[1]), match[2]);
}
const rows = [];
for (const line of stats.split("\n")) {
const six = /^\s*(\d+)\s+(\d+)\s+(\d+)\s+([\d.]+)\s+(\d+)\s+(.*)$/.exec(line);
const five = six ? undefined : /^\s*(\d+)\s+(\d+)\s+([\d.]+)\s+(\d+)\s+(.*)$/.exec(line);
const match = six ?? five;
if (!match)
continue;
const pid = Number(match[1]);
const pgid = six ? Number(match[3]) : 0;
const command = (six ? match[6] : match[5]).trim();
rows.push({ pid, ppid: Number(match[2]), pgid, cpu: Number(six ? match[4] : match[3]), rssKB: Number(six ? match[5] : match[4]),
command, args: argv.get(pid) ?? command });
}
return rows;
}
/** What kind of heavy job a process is, if any. */
export function heavyKind(row) {
const base = path.basename(row.command);
const args = row.args;
if (base === "launchd_sim") {
return { kind: "simulator", name: /XCTestDevices|Clone \d+ of /.test(args) ? "Simulator test clone" : "Simulator" };
}
if (/^qemu-system/.test(base) || base === "emulator" || /^emulator\d*-/.test(base))
return { kind: "emulator", name: "Android emulator" };
if (base === "xcodebuild")
return { kind: "xcodebuild", name: "xcodebuild" };
if (base === "java") {
if (/Gradle(?:Daemon|Worker|Main)|gradle-launcher|org\.gradle\./.test(args))
return { kind: "gradle", name: /Kotlin/.test(args) ? "Kotlin daemon" : "Gradle" };
if (/KotlinCompileDaemon/.test(args))
return { kind: "gradle", name: "Kotlin daemon" };
return { kind: "java", name: "Java" };
}
if (base === "codex" || base === "node" && /^\S*node\s+\S*\/codex(?:\.js)?(?:\s|$)/.test(args)) {
const service = /^\S+\s+(app-server|mcp-server)(?:\s|$)/.exec(args)?.[1];
return { kind: "codex", name: service === "app-server" ? "Codex app server" : service === "mcp-server" ? "Codex MCP server" : "Codex" };
}
if (base === "opencode" || base === ".opencode")
return { kind: "opencode", name: "OpenCode" };
if (base === "claude" || /(?:^|\/)claude\/versions\/\d+\.\d+\.\d+$/.test(row.command))
return { kind: "claude", name: "Claude Code" };
return undefined;
}
/**
* The heavy jobs in a process table. Every process counts toward its nearest
* heavy ancestor-or-self, so a Codex worker running xcodebuild shows both,
* each with only its own share. Any other program shows as `busy` when its
* processes together hold half a core. Known programs below both a core's
* tenth and 200 MiB are left out, including simulators and emulators.
* A memory-only row can be an idle session or helper; it is not evidence of
* agent activity. Counts include OS helpers, not just the named executable.
* On Linux, ps pcpu is a process lifetime average, so these cutoffs can miss
* daemons that spike briefly.
*/
export function heavyProcesses(rows, owners = new Map(), jobs = [], limit = 12) {
const byPid = new Map(rows.map(row => [row.pid, row]));
const kinds = new Map();
for (const row of rows) {
const kind = heavyKind(row);
if (kind)
kinds.set(row.pid, kind);
}
// npm's node launcher and its native Codex child are one process group.
// Keep independently launched/nested Codex workers as separate groups.
for (const row of rows) {
const parent = byPid.get(row.ppid);
if (kinds.get(row.pid)?.kind === "codex" && parent && path.basename(parent.command) === "node" && kinds.get(parent.pid)?.kind === "codex") {
kinds.set(parent.pid, kinds.get(row.pid));
kinds.delete(row.pid);
}
}
// Registered process groups: their pane names the job even when a detached
// process has been reparented away from it.
const jobsByPgid = jobPgids(jobs);
const totals = new Map();
const busy = new Map();
const claim = (row) => {
for (let at = row, hops = 0; at && hops < 64; at = byPid.get(at.ppid), hops++) {
if (kinds.has(at.pid))
return at.pid;
if (at.ppid === at.pid || at.ppid <= 1)
break;
}
return undefined;
};
// The pane that owns a process: by the registered process group first, then
// by following its ancestry to a pane's shell or foreground process.
const owner = (row) => {
for (let at = row, hops = 0; at && hops < 64; at = byPid.get(at.ppid), hops++) {
const pane = owners.get(at.pid);
if (pane)
return pane;
const job = jobsByPgid.get(at.pgid);
if (job?.pane)
return job.pane;
if (at.ppid === at.pid || at.ppid <= 1)
break;
}
return jobsByPgid.get(row.pgid)?.pane;
};
const trackedRoots = new Set();
for (const row of rows) {
const root = claim(row);
if (root !== undefined && jobsByPgid.has(row.pgid))
trackedRoots.add(root);
}
for (const row of rows) {
const root = claim(row);
const tracked = jobsByPgid.has(row.pgid) || (root !== undefined && trackedRoots.has(root));
if (root === undefined) {
// Unclaimed work groups by program: twelve swift-frontends at 40% each
// are one busy job. A registered group keeps its own bucket, so two
// workers' helpers are not merged under one pane.
const name = path.basename(row.command).slice(0, 60);
const key = tracked ? `job:${row.pgid}\0${name}` : name;
const group = busy.get(key);
if (!group)
busy.set(key, { kind: "busy", name, pid: row.pid, processes: 1, cpuPercent: row.cpu, memoryBytes: row.rssKB * 1024, top: row.cpu, row, ...(tracked ? { tracked: true } : {}) });
else {
group.processes++;
group.cpuPercent += row.cpu;
group.memoryBytes += row.rssKB * 1024;
if (tracked)
group.tracked = true;
if (row.cpu > group.top) {
group.top = row.cpu;
group.pid = row.pid;
group.row = row;
}
}
continue;
}
let total = totals.get(root);
if (!total) {
const { kind, name } = kinds.get(root);
const pane = owner(byPid.get(root));
total = { kind, name, pid: root, processes: 0, cpuPercent: 0, memoryBytes: 0, ...(tracked ? { tracked: true } : {}),
...(pane ? { pane } : {}), ...(pane?.agent ? { agent: pane.agent } : {}) };
totals.set(root, total);
}
total.processes++;
total.cpuPercent += row.cpu;
total.memoryBytes += row.rssKB * 1024;
if (tracked)
total.tracked = true;
}
for (const { top: _top, row, ...group } of busy.values()) {
if (group.cpuPercent < 50 && !group.tracked)
continue;
const pane = owner(row);
totals.set(group.pid, { ...group, ...(pane ? { pane } : {}), ...(pane?.agent ? { agent: pane.agent } : {}) });
}
return [...totals.values()]
.filter(item => item.kind === "busy" || item.cpuPercent >= 10 || item.memoryBytes >= 200 * 1024 ** 2 || item.tracked)
.map(item => ({ ...item, cpuPercent: Math.round(item.cpuPercent * 10) / 10,
resourceReason: (item.cpuPercent >= 10 ? "cpu" : item.memoryBytes >= 200 * 1024 ** 2 ? "memory" : "tracked") }))
.sort((a, b) => b.cpuPercent - a.cpuPercent || b.memoryBytes - a.memoryBytes)
.slice(0, limit);
}
/** `pmset -g batt`: "Now drawing from 'AC Power'" then " -InternalBattery-0 (id=…) 87%; charging; …". */
export function parsePmset(output) {
const match = /(\d{1,3})%;\s*([^;]+);/.exec(output);
if (!match)
return undefined;
const state = match[2].trim().toLowerCase();
return { percent: Math.min(100, Number(match[1])), charging: state === "charging" || state === "charged" || state === "finishing charge",
onAC: /'AC Power'/.test(output) };
}
/** `sysctl vm.swapusage`: "total = 2048.00M used = 798.50M free = …". */
export function parseSwap(output) {
const match = /used\s*=\s*([\d.]+)([KMG])/.exec(output);
if (!match)
return undefined;
return Math.round(Number(match[1]) * { K: 1024, M: 1024 ** 2, G: GB }[match[2]]);
}
/** /proc/meminfo: MemTotal, MemAvailable and swap, in kB. */
export function parseMeminfo(text) {
const field = (name) => { const match = new RegExp(`^${name}:\\s+(\\d+)`, "m").exec(text); return match ? Number(match[1]) * 1024 : undefined; };
const total = field("MemTotal") ?? totalmem(), available = field("MemAvailable");
const swapTotal = field("SwapTotal"), swapFree = field("SwapFree");
const availablePercent = available !== undefined && total ? Math.round(available / total * 100) : undefined;
return { totalBytes: total, ...(availablePercent !== undefined ? { availablePercent, pressure: availablePercent < 5 ? "critical" : availablePercent < 15 ? "warn" : "normal" } : {}),
...(swapTotal !== undefined && swapFree !== undefined ? { swapUsedBytes: swapTotal - swapFree } : {}) };
}
/** Gauge fill and warnings from the numbers. */
export function assess(value) {
const cpu = Math.min(1, value.cpu.loadPerCore / 2);
const memory = value.memory.pressure === "critical" ? 1
: Math.max(value.memory.pressure === "warn" ? 0.75 : 0, value.memory.availablePercent === undefined ? 0 : 1 - value.memory.availablePercent / 100);
const free = value.disk?.freeBytes, total = value.disk?.totalBytes;
// Plenty of room reads by fraction used; under 50 GB it climbs to full at 10 GB.
const disk = free === undefined || !total ? 0
: free >= 50 * GB ? 0.5 * (1 - free / total) : Math.min(1, 0.5 + (50 * GB - free) / (80 * GB));
const round = (n) => Math.round(Math.max(0, n) * 100) / 100;
const warnings = [];
if (value.cpu.loadPerCore > 2)
warnings.push("load-high");
if (value.memory.pressure === "critical" || (value.memory.availablePercent ?? 100) < 5)
warnings.push("memory-low");
if (free !== undefined && free < LOW_DISK_BYTES)
warnings.push("disk-low");
if (value.battery && !value.battery.onAC && value.battery.percent < 15)
warnings.push("battery-low");
const overall = Math.max(cpu, memory, disk);
return { pressure: { cpu: round(cpu), memory: round(memory), disk: round(disk), overall: round(overall) },
level: warnings.some(w => w !== "battery-low") || overall >= 0.9 ? "stressed" : overall >= 0.6 ? "busy" : "ok", warnings };
}
async function run(file, args) {
return (await exec(file, args, { timeout: 4_000, maxBuffer: 8 * 1024 * 1024, env: { ...process.env, LC_ALL: "C" } })).stdout;
}
async function darwinMemory() {
const [level, pressure, swap] = await Promise.all([
run("/usr/sbin/sysctl", ["-n", "kern.memorystatus_level"]).catch(() => ""),
run("/usr/sbin/sysctl", ["-n", "kern.memorystatus_vm_pressure_level"]).catch(() => ""),
run("/usr/sbin/sysctl", ["-n", "vm.swapusage"]).catch(() => ""),
]);
const available = Number(level.trim()), code = Number(pressure.trim()), swapUsedBytes = parseSwap(swap);
return { totalBytes: totalmem(),
...(level.trim() && Number.isFinite(available) ? { availablePercent: available } : {}),
...(pressure.trim() && Number.isFinite(code) ? { pressure: code >= 4 ? "critical" : code >= 2 ? "warn" : "normal" } : {}),
...(swapUsedBytes !== undefined ? { swapUsedBytes } : {}) };
}
async function linuxBattery() {
const root = "/sys/class/power_supply";
const names = await readdir(root).catch(() => []);
const battery = names.find(name => /^BAT/.test(name));
if (!battery)
return undefined;
const read = (name, field) => readFile(path.join(root, name, field), "utf8").then(text => text.trim()).catch(() => "");
const [capacity, status] = await Promise.all([read(battery, "capacity"), read(battery, "status")]);
const online = await Promise.all(names.filter(name => !/^BAT/.test(name)).map(name => read(name, "online")));
const percent = Number(capacity);
if (!capacity || !Number.isFinite(percent))
return undefined;
return { percent, charging: status === "Charging" || status === "Full", onAC: online.includes("1") };
}
async function processTable() {
const [stats, args, elapsed] = await Promise.all([run("/bin/ps", ["-axo", "pid=,ppid=,pgid=,pcpu=,rss=,comm="]), run("/bin/ps", ["-axo", "pid=,args="]),
run("/bin/ps", ["-axo", "pid=,etime="]).catch(() => "")]);
return withStartTimes(parsePs(stats, args), elapsed, Date.now());
}
/** Seconds from `ps`'s `etime` (`[[dd-]hh:]mm:ss`), or undefined. */
export function parseEtime(value) {
const match = /^(?:(\d+)-)?(?:(\d+):)?(\d+):(\d+)$/.exec(value.trim());
if (!match)
return undefined;
return Number(match[1] ?? 0) * 86_400 + Number(match[2] ?? 0) * 3_600 + Number(match[3]) * 60 + Number(match[4]);
}
/** Each row's start time (epoch ms, to about a second) from a `pid=,etime=`
* listing. The job registry checks a process group's leader by it, so a group
* id the system reused for another program is never signalled. */
export function withStartTimes(rows, elapsed, now) {
const started = new Map();
for (const line of elapsed.split("\n")) {
const match = /^\s*(\d+)\s+(\S+)\s*$/.exec(line);
const seconds = match ? parseEtime(match[2]) : undefined;
if (match && seconds !== undefined)
started.set(Number(match[1]), now - seconds * 1000);
}
return rows.map(row => started.has(row.pid) ? { ...row, startedAt: started.get(row.pid) } : row);
}
/** Every pane of every terminal server this computer knows, or undefined when
* any server's snapshot could not be read. Cleanup ends a job only when its
* pane is missing from a complete listing: a pane that merely failed to
* answer, or sat past a display cap, must never look gone. */
export async function livePanes() {
const panes = new Set(), servers = new Set();
for (const server of await recentServers()) {
const name = String(server.session);
const s = await sharedSnapshot(name, RESOURCES_MAX_AGE_MS).catch(() => undefined);
if (!s)
return undefined;
servers.add(name);
for (const pane of objects(s.panes))
panes.add(paneKey({ server: name, pane: String(pane.pane_id) }));
}
return { panes, servers };
}
/** Every pid a Herdr or tmux pane holds (its shell and foreground), for naming a job's pane. */
export async function paneOwners() {
const owners = new Map();
const { terminalProvider } = await import("./terminal.js");
const provider = terminalProvider();
for (const server of (await recentServers()).slice(0, 8)) {
const name = String(server.session);
const s = await sharedSnapshot(name, RESOURCES_MAX_AGE_MS).catch(() => undefined);
if (!s)
continue;
const workspaces = new Map(objects(s.workspaces).map(w => [String(w.workspace_id), typeof w.label === "string" ? w.label : undefined]));
await Promise.all(objects(s.panes).slice(0, 64).map(async (pane) => {
const processes = await provider.processes(name, String(pane.pane_id)).catch(() => undefined);
if (!processes)
return;
const ref = { server: name, pane: String(pane.pane_id),
...(workspaces.get(String(pane.workspace_id)) ? { workspace: workspaces.get(String(pane.workspace_id)) } : {}),
...(typeof pane.agent === "string" ? { agent: pane.agent } : {}),
...(typeof pane.label === "string" && pane.label ? { label: pane.label } : {}) };
for (const pid of [processes.shellPid, ...processes.foregroundPids])
if (typeof pid === "number" && pid > 0)
owners.set(pid, ref);
}));
}
return owners;
}
export async function collectResources(deps) {
const [one, five, fifteen] = loadavg();
const cores = cpus().length || 1;
const platform = deps.platform;
const registry = deps.jobs ?? new JobRegistry();
const [memory, disk, battery, rows, registered, hardware] = await Promise.all([
platform === "darwin" ? darwinMemory()
: platform === "linux" ? readFile("/proc/meminfo", "utf8").then(parseMeminfo).catch(() => ({ totalBytes: totalmem() }))
: Promise.resolve({ totalBytes: totalmem() }),
statfs(deps.home()).then(info => ({ path: "~", totalBytes: info.blocks * info.bsize, freeBytes: info.bavail * info.bsize })).catch(() => undefined),
platform === "darwin" ? run("/usr/bin/pmset", ["-g", "batt"]).then(parsePmset).catch(() => undefined)
: platform === "linux" ? linuxBattery() : Promise.resolve(undefined),
deps.processes().catch(() => []),
registry.list().catch(() => []),
(deps.hardware ?? (() => computerHardware(platform)))().catch(() => undefined),
]);
// Pane pids cost one terminal call per pane: only when there is a job to name.
const preliminary = heavyProcesses(rows, new Map(), registered);
const owners = preliminary.length || registered.length ? await deps.owners().catch(() => new Map()) : new Map();
// Resolve registered workers' process groups and pane names from the table,
// so a detached job keeps the agent that owns it.
const jobs = owners.size ? await registry.reconcile(rows, owners).catch(() => registered) : registered;
const round = (n) => Math.round(n * 100) / 100;
const value = {
collectedAt: new Date(deps.now()).toISOString(), platform, uptimeSeconds: Math.round(uptime()),
cpu: { cores, load1: round(one), load5: round(five), load15: round(fifteen), loadPerCore: round(one / cores) },
memory, ...(disk ? { disk } : {}), ...(battery ? { battery } : {}), ...(hardware ? { hardware } : {}),
heavy: owners.size || jobs.length ? heavyProcesses(rows, owners, jobs) : preliminary,
};
return { ...value, ...assess(value) };
}
/** One collection at a time, reused for `RESOURCES_MAX_AGE_MS`. */
export class ResourceMonitor {
maxAgeMs;
cached;
pending;
deps;
registry;
constructor(deps = {}, maxAgeMs = RESOURCES_MAX_AGE_MS) {
this.maxAgeMs = maxAgeMs;
this.registry = deps.jobs ?? new JobRegistry();
this.deps = { platform: process.platform, now: Date.now, home: homeDir, processes: processTable, owners: paneOwners, panes: livePanes, ...deps, jobs: this.registry };
}
async read() {
if (this.cached && this.deps.now() - this.cached.at < this.maxAgeMs)
return this.cached.value;
this.pending ??= collectResources(this.deps)
.then(value => { this.cached = { at: this.deps.now(), value }; return value; })
.finally(() => { this.pending = undefined; });
return this.pending;
}
/**
* End only the registered process groups whose owning pane is gone. The
* registry (job-registry.ts) does the safety checks; a failed pane read ends
* nothing. Never the Hook's own service, never an owner's session, never an
* unregistered process.
*/
async cleanupJobs() {
const rows = await this.deps.processes().catch(() => []);
const owners = await this.deps.owners().catch(() => new Map());
// Gone means absent from a complete listing of every server's panes; the
// owners map skips panes whose process read failed, so it can't say that.
const live = await this.deps.panes().catch(() => undefined);
const report = await this.registry.cleanup(rows, live?.panes ?? new Set(), { owners, panesKnown: live !== undefined, ...(live ? { servers: live.servers } : {}) });
this.cached = undefined;
return report;
}
}