UNPKG

@phren/cli

Version:

Knowledge layer for AI agents - CLI, MCP server, and data layer

462 lines (461 loc) • 25.2 kB
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; } }