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 {