diff --git a/CHANGELOG.md b/CHANGELOG.md index 220bb88..4323021 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,18 @@ new version heading in the same commit. ## [Unreleased] +## [0.142.0] — 2026-07-13 +### Added +- **Settings → System → Host resources now breaks down RAM by agent session.** Alongside the host + totals, the panel lists each live session's resident memory — its process tree (shell → `claude`/node → + MCP subprocesses) — plus a fleet total (e.g. "4.6 GB · 8 live"), sorted heaviest-first. So you can see + exactly how much of the box's RAM the running agents are holding, and which session is the hog. Backed + by a new `SessionBackend.sessionRss()` (one `tmux list-panes` + one `ps -Ao pid,ppid,rss` snapshot, + summed over each pane's process subtree — portable across macOS/Linux), surfaced via `sessionMemory()` + on `TerminalManager` and the existing `GET /api/system`. RSS is approximate (shared library pages are + counted per process, so the sum slightly over-reports). Not measurable under the Linux uid-isolation + launcher backend (uid-private sockets) → shown as "not measurable here." + ## [0.141.2] — 2026-07-13 ### Fixed - **Slack: a plain message in a channel the bot sits in no longer gets the `/agent` help list.** The app @@ -30,6 +42,8 @@ new version heading in the same commit. beacon can't re-reap. The idle backstop (`reapIdleSessions`) likewise sweeps `done` unattended rows that still hold a live pane — cleaning up any that predate the fix or whose Stop beacon never landed. Net: a finished background run stops for real, and the session list stops showing it as "live". + +## [0.141.0] — 2026-07-13 ### Added - **Settings → System now shows live host resources.** A new "Host resources" panel reports memory used/free/total with a usage bar, CPU utilization (sampled server-side) + core count + model + load diff --git a/package-lock.json b/package-lock.json index cbd9d3d..3c6bfe7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "agent-os", - "version": "0.141.2", + "version": "0.142.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "agent-os", - "version": "0.141.2", + "version": "0.142.0", "license": "MIT", "bin": { "agent-os": "bin/agent-os" diff --git a/package.json b/package.json index 64894dc..9adabc4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "agent-os", - "version": "0.141.2", + "version": "0.142.0", "description": "A generic, governed operating system for running autonomous agents safely across brands. Ships with a local web console.", "license": "MIT", "type": "commonjs", diff --git a/src/edge/session-backend.ts b/src/edge/session-backend.ts index ae6457a..77d8040 100644 --- a/src/edge/session-backend.ts +++ b/src/edge/session-backend.ts @@ -40,6 +40,11 @@ export interface SessionBackend { injectText(space: string, tmuxName: string, text: string, submit: boolean): boolean; /** Live tmux session names, or null when liveness can't be polled (→ rely on end signals). */ aliveNames(): Set | null; + /** Per-session resident memory: tmux session name → summed RSS **in KiB** of that session's pane + * process tree (the shell + `claude`/node + its MCP subprocesses). Approximate — RSS counts shared + * pages (libraries) once per process, so a sum over the tree slightly over-reports. `null` when it + * can't be measured (launcher backend: uid-private sockets the app can't inspect). */ + sessionRss(): Map | null; /** Is a browser terminal currently ATTACHED to `tmuxName` (tmux has ≥1 client on the session)? * Distinguishes "a human is watching this live pane" from "running but unobserved" — the signal the * turn-end/idle reapers use to leave a taken-over run alone. `null` when it can't be determined @@ -144,6 +149,52 @@ export class LocalSessionBackend implements SessionBackend { return new Set((r.stdout || '').split('\n').filter(Boolean)); } + sessionRss(): Map | null { + // One tmux call maps every live pane to its root PID; one `ps` snapshot gives the whole process + // table. We then sum RSS over each pane's subtree (the shell → node/claude → MCP children). + // `-Ao pid=,ppid=,rss=` is portable across BSD (macOS) and GNU (Linux) ps; RSS is KiB on both. + const panes = spawnSync('tmux', ['-S', this.tmuxSocket, 'list-panes', '-a', '-F', '#{session_name} #{pane_pid}'], { encoding: 'utf8' }); + if (panes.error) return null; // couldn't poll tmux → unknown + if (panes.status !== 0) return new Map(); // no server / no sessions → nothing to measure + const ps = spawnSync('ps', ['-Ao', 'pid=,ppid=,rss='], { encoding: 'utf8' }); + if (ps.error || ps.status !== 0) return null; + + // Build pid → rss (KiB) and ppid → [children]. + const rssOf = new Map(); + const kids = new Map(); + for (const line of (ps.stdout || '').split('\n')) { + const m = line.trim().match(/^(\d+)\s+(\d+)\s+(\d+)$/); + if (!m) continue; + const pid = +m[1], ppid = +m[2], rss = +m[3]; + rssOf.set(pid, rss); + (kids.get(ppid) ?? kids.set(ppid, []).get(ppid)!).push(pid); + } + const subtreeRss = (root: number): number => { + let total = 0; + const stack = [root]; + const seen = new Set(); + while (stack.length) { + const pid = stack.pop()!; + if (seen.has(pid)) continue; // cycle guard (ppid reuse after a wrap is possible) + seen.add(pid); + total += rssOf.get(pid) ?? 0; + for (const c of kids.get(pid) ?? []) stack.push(c); + } + return total; + }; + + const out = new Map(); + for (const line of (panes.stdout || '').split('\n')) { + const sp = line.lastIndexOf(' '); + if (sp < 0) continue; + const name = line.slice(0, sp); + const pid = +line.slice(sp + 1); + if (!name || !Number.isFinite(pid)) continue; + out.set(name, (out.get(name) ?? 0) + subtreeRss(pid)); // a session may (rarely) have >1 pane + } + return out; + } + hasClient(_space: string, tmuxName: string): boolean | null { // `list-clients -t ` prints one line per attached ttyd/xterm client; empty → nobody watching. const r = spawnSync('tmux', ['-S', this.tmuxSocket, 'list-clients', '-t', tmuxName, '-F', '#{client_name}'], { encoding: 'utf8' }); @@ -223,6 +274,10 @@ export class LauncherSessionBackend implements SessionBackend { return null; } + sessionRss(): Map | null { + return null; // uid-private sockets — the app can't inspect member process trees (a launcher verb later). + } + hasClient(_space: string, _tmuxName: string): boolean | null { return null; // uid-private socket — attachment can't be polled from the app (→ timeout-reap fallback). } diff --git a/src/server.ts b/src/server.ts index 02a6923..58a8192 100644 --- a/src/server.ts +++ b/src/server.ts @@ -76,6 +76,7 @@ async function systemMetrics(tm: TerminalManager): Promise(); + const sessions = rows + .map((r) => ({ id: r.id, agent: r.agent, title: r.title, rss: (rss.get(r.tmux) ?? 0) * 1024 })) + .filter((s) => s.rss > 0) // drop rows whose pane already went away + .sort((a, b) => b.rss - a.rss); + return { available: true, totalRss: sessions.reduce((n, s) => n + s.rss, 0), sessions }; + } + /** Is this session's tmux shell still alive? (The automations guard against pile-ups.) */ isAlive(sessionId: string): boolean { const r = this.db.prepare('SELECT tmux, status FROM term_sessions WHERE id = ?').get<{ tmux: string; status: string }>(sessionId); diff --git a/web/src/App.tsx b/web/src/App.tsx index dd1d494..1071bc5 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -5315,7 +5315,8 @@ function CommentBox({ onSubmit }: { onSubmit: (text: string) => Promise }) function fmtBytes(n: number): string { if (n < 1024) return `${n} B` if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB` - return `${(n / (1024 * 1024)).toFixed(1)} MB` + if (n < 1024 ** 3) return `${(n / (1024 * 1024)).toFixed(1)} MB` + return `${(n / 1024 ** 3).toFixed(1)} GB` } /** Attachments section of the task drawer: upload (button/drop), list with download, delete. */ @@ -8090,6 +8091,33 @@ function HostResourcesPanel() { {m.cpu.model}{m.cpu.loadAvg.some((n) => n > 0) ? ` · load ${m.cpu.loadAvg.map((n) => n.toFixed(2)).join(' ')}` : ''}

+
+
+ Agent sessions + {m.sessions.available + ? {fmtBytes(m.sessions.totalRss)} · {m.sessions.sessions.length} live + : not measurable here} +
+ {m.sessions.available && ( + m.sessions.sessions.length === 0 + ?

No running sessions.

+ :
+ {m.sessions.sessions.slice(0, 8).map((s) => ( +
+
+ {s.agent} + · {s.title} +
+
{fmtBytes(s.rss)}
+
+ ))} + {m.sessions.sessions.length > 8 && ( +
+{m.sessions.sessions.length - 8} more
+ )} +
+ )} +

Resident memory per session (shell + claude + MCP). Approximate — shared pages counted per process.

+
{([ ['Node RSS', `${fmtGB(m.process.rss)} (heap ${fmtGB(m.process.heapUsed)} / ${fmtGB(m.process.heapTotal)})`], diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index a689bb7..45864ed 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -119,6 +119,9 @@ export interface SystemMetrics { process: { rss: number; heapUsed: number; heapTotal: number; uptime: number } host: { platform: string; arch: string; release: string; hostname: string; uptime: number } runningSessions: number + /** Per-session resident memory (bytes). `available:false` under uid-isolation (unmeasurable). RSS is + * approximate — shared library pages are counted once per process, so the sum slightly over-reports. */ + sessions: { available: boolean; totalRss: number; sessions: { id: string; agent: string; title: string; rss: number }[] } error?: string } export interface Session {