/** * @file ai-procs.js * @description Server mode, one Linux user per account (S3): counts the AI * Tools sessions a Linux account is running RIGHT NOW, from /proc — whoever * started them (a dashboard pane, or `claude` typed into a terminal tab). * A session is a process whose program is one of the catalog tools' binaries * (`claude`, `codex`, `agy`, `cursor-agent`, `omp`) with no such process * among its ancestors, so a nested `claude -p`, Codex's native child behind * its npm launcher, or a subagent never counts twice. Used by `spawnRun` * (ESESSIONLIMIT before a pane starts) and by `scripts/member-guard.js` * (refuses the prompt of a session over the limit started by hand), which * runs AS the member and sees only that member's processes (hidepid) — that * is all it needs. Pure Node built-ins: the guard runs from /opt/agenthub/app. * @author Nguyễn Ngọc Trí Vĩ */ const fs = require("node:fs"); const path = require("node:path"); /** The catalog tools' binary names (server/data/ai-tools/*.json `binary`). */ const AI_BINS = new Set(["claude", "codex", "agy", "cursor-agent", "omp"]); /** Interpreters whose first argument names the tool (an npm launcher). */ const INTERPRETERS = new Set(["node", "nodejs", "bun", "deno"]); /** A tool's server, not a session (the host's omp share). */ const NOT_A_SESSION = new Set(["auth-gateway", "auth-broker", "mcp", "daemon"]); const DEFAULT_LIMIT = 4; const CONF = "/etc/agenthub/server.conf"; const base = (p) => path.basename(String(p || "")).replace(/\.(c|m)?js$/, ""); /** `{pid, ppid, uid, start, ai}` for one /proc entry, or null once it is gone. */ function readProc(pid, procRoot = "/proc") { try { const dir = path.join(procRoot, String(pid)); const stat = fs.readFileSync(path.join(dir, "stat"), "utf8"); // comm may hold spaces and parentheses: the fields start after the LAST ')'. const f = stat.slice(stat.lastIndexOf(")") + 2).split(" "); const argv = fs.readFileSync(path.join(dir, "cmdline"), "utf8").split("\0").filter(Boolean); const uid = fs.statSync(dir).uid; let name = base(argv[0]); let rest = argv.slice(1); if (INTERPRETERS.has(name)) { const i = rest.findIndex((a) => !a.startsWith("-")); name = i === -1 ? "" : base(rest[i]); rest = i === -1 ? [] : rest.slice(i + 1); } const ai = AI_BINS.has(name) && !rest.some((a) => NOT_A_SESSION.has(a)); return { pid: Number(pid), ppid: Number(f[1]), start: Number(f[19]), uid, ai }; } catch { return null; // exited while we looked, or not ours to read } } /** * The account's top-level AI sessions, oldest first. * @param {number} uid * @returns {{pid:number, start:number}[]} */ function sessionsOf(uid, procRoot = "/proc") { const procs = new Map(); let names = []; try { names = fs.readdirSync(procRoot); } catch { return []; } for (const n of names) { if (!/^\d+$/.test(n)) continue; const p = readProc(n, procRoot); if (p) procs.set(p.pid, p); } const out = []; for (const p of procs.values()) { if (!p.ai || p.uid !== uid) continue; let nested = false; const seen = new Set(); for (let a = procs.get(p.ppid); a && !seen.has(a.pid); a = procs.get(a.ppid)) { seen.add(a.pid); if (a.ai) { nested = true; break; } } if (!nested) out.push({ pid: p.pid, start: p.start }); } return out.sort((a, b) => a.start - b.start || a.pid - b.pid); } /** The pid of the top-level AI session `pid` runs under (itself included), or null. */ function sessionOf(pid, procRoot = "/proc") { let top = null; const seen = new Set(); for (let p = readProc(pid, procRoot); p && !seen.has(p.pid); p = readProc(p.ppid, procRoot)) { seen.add(p.pid); if (p.ai) top = p.pid; if (p.ppid <= 1) break; } return top; } /** * The most AI sessions one member may run at once: `MAX_AI_SESSIONS` in * server.conf (0 = no limit), else 4. */ function sessionLimit(confFile = CONF) { try { const m = fs.readFileSync(confFile, "utf8").match(/^MAX_AI_SESSIONS=(\d+)\s*$/m); if (m) return Number(m[1]); } catch { /* no server.conf: the default */ } return DEFAULT_LIMIT; } module.exports = { AI_BINS, DEFAULT_LIMIT, readProc, sessionsOf, sessionOf, sessionLimit };