Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
55 changes: 55 additions & 0 deletions src/edge/session-backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> | 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<string, number> | 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
Expand Down Expand Up @@ -144,6 +149,52 @@ export class LocalSessionBackend implements SessionBackend {
return new Set((r.stdout || '').split('\n').filter(Boolean));
}

sessionRss(): Map<string, number> | 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<number, number>();
const kids = new Map<number, number[]>();
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<number>();
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<string, number>();
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 <session>` 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' });
Expand Down Expand Up @@ -223,6 +274,10 @@ export class LauncherSessionBackend implements SessionBackend {
return null;
}

sessionRss(): Map<string, number> | 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).
}
Expand Down
1 change: 1 addition & 0 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ async function systemMetrics(tm: TerminalManager): Promise<Record<string, unknow
uptime: nodeOs.uptime(),
},
runningSessions: tm.aliveSessionCount(),
sessions: tm.sessionMemory(),
};
}

Expand Down
19 changes: 19 additions & 0 deletions src/terminal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -708,6 +708,25 @@ export class TerminalManager {
return n;
}

/**
* Per-session resident memory for the live running set — what each agent session's process tree
* (shell → claude/node → MCP subprocesses) currently occupies. Joins the running rows against the
* backend's `sessionRss` map (keyed by tmux name). `available:false` when the backend can't measure
* it (launcher/uid-isolation backend, or a transient tmux/ps failure). RSS is approximate (shared
* library pages are counted per process). Bytes out (KiB×1024) so the API speaks one unit.
*/
sessionMemory(): { available: boolean; totalRss: number; sessions: { id: string; agent: string; title: string; rss: number }[] } {
const rss = this.backend.sessionRss();
if (!rss) return { available: false, totalRss: 0, sessions: [] };
const rows = this.db.prepare("SELECT id, agent, title, tmux FROM term_sessions WHERE status = 'running'")
.all<{ id: string; agent: string; title: string; tmux: string }>();
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);
Expand Down
30 changes: 29 additions & 1 deletion web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5315,7 +5315,8 @@ function CommentBox({ onSubmit }: { onSubmit: (text: string) => Promise<void> })
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. */
Expand Down Expand Up @@ -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(' ')}` : ''}
</p>
</div>
<div className="space-y-1.5">
<div className="flex items-baseline justify-between text-sm">
<span className="font-medium">Agent sessions</span>
{m.sessions.available
? <span className="font-mono text-xs text-muted-foreground">{fmtBytes(m.sessions.totalRss)} · {m.sessions.sessions.length} live</span>
: <span className="text-xs text-muted-foreground">not measurable here</span>}
</div>
{m.sessions.available && (
m.sessions.sessions.length === 0
? <p className="text-xs text-muted-foreground">No running sessions.</p>
: <dl className="divide-y rounded-md border">
{m.sessions.sessions.slice(0, 8).map((s) => (
<div key={s.id} className="flex items-baseline gap-3 px-3 py-1.5">
<dt className="min-w-0 flex-1 truncate text-xs" title={`${s.agent} · ${s.title}`}>
<span className="font-medium">{s.agent}</span>
<span className="text-muted-foreground"> · {s.title}</span>
</dt>
<dd className="shrink-0 font-mono text-xs text-muted-foreground">{fmtBytes(s.rss)}</dd>
</div>
))}
{m.sessions.sessions.length > 8 && (
<div className="px-3 py-1.5 text-xs text-muted-foreground">+{m.sessions.sessions.length - 8} more</div>
)}
</dl>
)}
<p className="text-xs text-muted-foreground">Resident memory per session (shell + claude + MCP). Approximate — shared pages counted per process.</p>
</div>
<dl className="divide-y rounded-md border">
{([
['Node RSS', `${fmtGB(m.process.rss)} (heap ${fmtGB(m.process.heapUsed)} / ${fmtGB(m.process.heapTotal)})`],
Expand Down
3 changes: 3 additions & 0 deletions web/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading