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
12 changes: 12 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.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
average, Node process RSS/heap, process & host uptime, running-session count, and host platform/arch —
polled every 4s. Backed by a new owner/admin-gated `GET /api/system` (Node `os` + `process.memoryUsage`).
- **"Stop all sessions" button in Settings → System.** One click halts every running agent session
tenant-wide via `POST /api/sessions/stop-all` (owner/admin, audited `sessions.stop_all`) — a softer
sibling of the Governance kill switch: it stops the fleet but leaves the gate open, so new runs can still
be launched. Reuses the existing `TerminalManager.stopAllRunning`, so each session's inbox/audit reflect
the halt.

## [0.140.0] — 2026-07-13
### Added
- **`video_understand`: agents can now "watch" a video (video → text).** Claude can't see video natively;
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.140.0",
"version": "0.141.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
56 changes: 56 additions & 0 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import * as http from 'http';
import * as fs from 'fs';
import * as path from 'path';
import * as crypto from 'crypto';
import * as nodeOs from 'node:os';
import { AgentOS, loadAgentOS } from './kernel';
import { VERSION } from './version';
import { TenantRegistry, TenantRuntime, notifyLoginLink } from './tenant-registry';
Expand Down Expand Up @@ -36,6 +37,48 @@ import { AgentManifest, ApprovalRequest, Branding, EmbeddingsConfig, ENV_NAME, I
import { AgentConfigSnapshot } from './state/agent-revisions';
import { computeAgentStats, computeAgentStat } from './state/agent-stats';

/** Sum busy + idle CPU tick counters across all cores (for a sampled utilization %). */
function cpuTicks(): { idle: number; total: number } {
let idle = 0, total = 0;
for (const c of nodeOs.cpus()) {
for (const t of Object.values(c.times)) total += t;
idle += c.times.idle;
}
return { idle, total };
}

/** Host resource snapshot for Settings → System. Samples CPU over ~120ms to get a real busy %. */
async function systemMetrics(tm: TerminalManager): Promise<Record<string, unknown>> {
const a = cpuTicks();
await new Promise((r) => setTimeout(r, 120));
const b = cpuTicks();
const idleDelta = b.idle - a.idle;
const totalDelta = b.total - a.total;
const cpuUsage = totalDelta > 0 ? Math.max(0, Math.min(1, 1 - idleDelta / totalDelta)) : 0;
const cpus = nodeOs.cpus();
const total = nodeOs.totalmem();
const free = nodeOs.freemem();
const mem = process.memoryUsage();
return {
mem: { total, free, used: total - free, usedPct: total > 0 ? (total - free) / total : 0 },
cpu: {
count: cpus.length,
model: cpus[0]?.model?.trim() || 'unknown',
usagePct: cpuUsage,
loadAvg: nodeOs.loadavg(), // [1, 5, 15] min — always 0 on Windows
},
process: { rss: mem.rss, heapUsed: mem.heapUsed, heapTotal: mem.heapTotal, uptime: process.uptime() },
host: {
platform: nodeOs.platform(),
arch: nodeOs.arch(),
release: nodeOs.release(),
hostname: nodeOs.hostname(),
uptime: nodeOs.uptime(),
},
runningSessions: tm.aliveSessionCount(),
};
}

/** Settings → Memory view: stored backend config with secrets redacted to `…Set` booleans. */
interface EmbeddingsView { provider: 'openai' | 'ollama'; url: string; model: string; dimensions?: number; apiKeySet: boolean }
interface MemorySettingsView {
Expand Down Expand Up @@ -2718,6 +2761,19 @@ async function handle(os: AgentOS, tm: TerminalManager, autos: Automations, req:
return sendJson(res, 200, { ok: true, ...state, halted });
}

// ── host resource metrics (Settings → System: RAM / CPU / uptime) ──
if (method === 'GET' && p === '/api/system') {
if (!isAdmin(me)) return sendJson(res, 403, { error: 'owner or admin required' });
return sendJson(res, 200, await systemMetrics(tm));
}
// ── stop every running session (softer sibling of the kill switch; leaves the gate open) ──
if (method === 'POST' && p === '/api/sessions/stop-all') {
if (!isAdmin(me)) return sendJson(res, 403, { error: 'owner or admin required' });
const halted = tm.stopAllRunning(me.email);
os.audit.append({ ts: Date.now(), runId: '-', tenant: os.tenant, principal: me.email, type: 'sessions.stop_all', data: { halted } });
return sendJson(res, 200, { ok: true, halted });
}

// ── company settings (workspace-wide context injected into every claude-code agent) ──
if (method === 'GET' && p === '/api/settings') {
if (!isAdmin(me)) return sendJson(res, 403, { error: 'owner or admin required' });
Expand Down
121 changes: 120 additions & 1 deletion web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { Input } from '@/components/ui/input'
import { Separator } from '@/components/ui/separator'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator } from '@/components/ui/dropdown-menu'
import { api, EFFORTS, PERMISSION_MODES, type PermissionMode, type StateResp, type AgentInfo, type Session, type Msg, type Member, type Role, type TeamResp, type MemberIdentity, type IdentityProvider, IDENTITY_PROVIDERS, type Automation, type Task, type TaskEvent, type TaskAttachment, type TaskStatus, type AddTaskReq, type Goal, type GoalEvent, type GoalStatus, type GoalCounts, type GoalProgress, type AddGoalReq, type MemoryRecord, type MemoryHealth, type MemoryBackend, type MemorySettings, type MemorySettingsReq, type OllamaStatus, type KbPage, type KbRevision, type AgentRevision, type AgentStats, type Recommendation, type PolicyDocument, type PolicyRule, type PolicyOutcome, type PolicyOp, type DirListing, type FileEntry, type FileContent, type Artifact, type SkillSummary, type SkillsResp, type CatalogSkill, type CatalogAgent, type SkillSource, type RemoteSkill, type SkillshHit, type SkillRequest, type IntegrationsResp, type SlackStatus, type DiscordStatus, type AuditEvent, type Effort, type RuntimeTuning, type SecretMeta, type UpdateStatus, type UpdateApplyResult, type ActivityEvent, type ActivitySummaryRow } from '@/lib/api'
import { api, EFFORTS, PERMISSION_MODES, type PermissionMode, type StateResp, type AgentInfo, type Session, type Msg, type Member, type Role, type TeamResp, type MemberIdentity, type IdentityProvider, IDENTITY_PROVIDERS, type Automation, type Task, type TaskEvent, type TaskAttachment, type TaskStatus, type AddTaskReq, type Goal, type GoalEvent, type GoalStatus, type GoalCounts, type GoalProgress, type AddGoalReq, type MemoryRecord, type MemoryHealth, type MemoryBackend, type MemorySettings, type MemorySettingsReq, type OllamaStatus, type KbPage, type KbRevision, type AgentRevision, type AgentStats, type Recommendation, type PolicyDocument, type PolicyRule, type PolicyOutcome, type PolicyOp, type DirListing, type FileEntry, type FileContent, type Artifact, type SkillSummary, type SkillsResp, type CatalogSkill, type CatalogAgent, type SkillSource, type RemoteSkill, type SkillshHit, type SkillRequest, type IntegrationsResp, type SlackStatus, type DiscordStatus, type AuditEvent, type Effort, type RuntimeTuning, type SecretMeta, type UpdateStatus, type UpdateApplyResult, type ActivityEvent, type ActivitySummaryRow, type SystemMetrics } from '@/lib/api'
import { type Branding, type PublicBranding, type NotificationPrefs, DEFAULT_NOTIFICATION_PREFS } from '@/lib/api'
import { applyAccent, applyFavicon, faviconDataUri, readableOn } from '@/lib/branding'
import { ConnectorsPage } from '@/connectors'
Expand Down Expand Up @@ -7989,6 +7989,8 @@ function SystemSettings({ state, me }: { state: StateResp | null; me: Member })
return (
<div className="space-y-4">
<SoftwarePanel me={me} />
<HostResourcesPanel />
<StopAllPanel />
<Card>
<CardContent className="space-y-4 p-4">
<p className="text-sm text-muted-foreground">
Expand Down Expand Up @@ -8025,6 +8027,123 @@ function SystemSettings({ state, me }: { state: StateResp | null; me: Member })
)
}

/** Format a byte count as GB with one decimal (e.g. 12.4 GB). */
function fmtGB(bytes: number): string {
return (bytes / 1024 ** 3).toFixed(1) + ' GB'
}
/** Format a seconds duration compactly (e.g. 3d 4h, 5h 12m, 8m). */
function fmtUptime(sec: number): string {
const d = Math.floor(sec / 86400), h = Math.floor((sec % 86400) / 3600), m = Math.floor((sec % 3600) / 60)
if (d > 0) return `${d}d ${h}h`
if (h > 0) return `${h}h ${m}m`
return `${m}m`
}

/** A labelled usage bar: fraction 0–1, tinted amber ≥75% and red ≥90%. */
function UsageBar({ pct }: { pct: number }) {
const clamped = Math.max(0, Math.min(1, pct))
const tone = clamped >= 0.9 ? 'bg-red-500' : clamped >= 0.75 ? 'bg-amber-500' : 'bg-emerald-500'
return (
<div className="h-2 w-full overflow-hidden rounded-full bg-muted">
<div className={`h-full rounded-full transition-all ${tone}`} style={{ width: `${clamped * 100}%` }} />
</div>
)
}

/**
* Settings → System → Host resources — live RAM / CPU / uptime for the box this instance runs on.
* Polls `/api/system` every 4s (CPU % is a short server-side sample). Owner/admin only (route-gated).
*/
function HostResourcesPanel() {
const [m, setM] = useState<SystemMetrics | null>(null)
const [err, setErr] = useState('')
useEffect(() => {
let live = true
const load = () => api.system().then((v) => { if (!live) return; if (v.error) setErr(v.error); else { setErr(''); setM(v) } }).catch(() => live && setErr('Could not read system metrics.'))
load()
const t = setInterval(load, 4000)
return () => { live = false; clearInterval(t) }
}, [])
return (
<Card>
<CardContent className="space-y-4 p-4">
<div className="flex items-center gap-2 text-sm font-semibold"><Cpu className="h-4 w-4" /> Host resources</div>
{err ? <p className="text-sm text-muted-foreground">{err}</p>
: !m ? <p className="text-sm text-muted-foreground">Reading…</p>
: (
<>
<div className="space-y-1.5">
<div className="flex items-baseline justify-between text-sm">
<span className="font-medium">Memory</span>
<span className="font-mono text-xs text-muted-foreground">{fmtGB(m.mem.used)} / {fmtGB(m.mem.total)} · {Math.round(m.mem.usedPct * 100)}%</span>
</div>
<UsageBar pct={m.mem.usedPct} />
<p className="text-xs text-muted-foreground">{fmtGB(m.mem.free)} free</p>
</div>
<div className="space-y-1.5">
<div className="flex items-baseline justify-between text-sm">
<span className="font-medium">CPU</span>
<span className="font-mono text-xs text-muted-foreground">{Math.round(m.cpu.usagePct * 100)}% · {m.cpu.count} cores</span>
</div>
<UsageBar pct={m.cpu.usagePct} />
<p className="truncate text-xs text-muted-foreground" title={m.cpu.model}>
{m.cpu.model}{m.cpu.loadAvg.some((n) => n > 0) ? ` · load ${m.cpu.loadAvg.map((n) => n.toFixed(2)).join(' ')}` : ''}
</p>
</div>
<dl className="divide-y rounded-md border">
{([
['Node RSS', `${fmtGB(m.process.rss)} (heap ${fmtGB(m.process.heapUsed)} / ${fmtGB(m.process.heapTotal)})`],
['Process up', fmtUptime(m.process.uptime)],
['Host up', fmtUptime(m.host.uptime)],
['Running sessions', String(m.runningSessions)],
['Host', `${m.host.hostname} · ${m.host.platform}/${m.host.arch}`],
] as [string, string][]).map(([label, value]) => (
<div key={label} className="flex items-baseline gap-4 px-3 py-2">
<dt className="w-32 shrink-0 text-xs font-medium text-muted-foreground">{label}</dt>
<dd className="min-w-0 break-all font-mono text-xs">{value}</dd>
</div>
))}
</dl>
</>
)}
</CardContent>
</Card>
)
}

/**
* Settings → System → Stop all sessions — halt every running agent session tenant-wide in one click.
* Softer sibling of the kill switch (Governance tab): it stops the fleet but leaves the gate open, so
* new runs can still be launched. Owner/admin only (route-gated).
*/
function StopAllPanel() {
const [busy, setBusy] = useState(false)
const [hint, setHint] = useState('')
const stopAll = async () => {
if (!confirm('Stop ALL running agent sessions now? Each is halted mid-task (its inbox + audit reflect it). New runs can still be launched afterward.')) return
setBusy(true); setHint('')
const r = await api.stopAllSessions()
setBusy(false)
setHint(r.error ? '⚠ ' + r.error : r.halted ? `Stopped ${r.halted} session${r.halted === 1 ? '' : 's'}.` : 'No running sessions to stop.')
}
return (
<Card>
<CardContent className="space-y-3 p-4">
<div className="flex items-center justify-between gap-3">
<div>
<div className="flex items-center gap-2 text-sm font-semibold"><Square className="h-4 w-4" /> Stop all sessions</div>
<p className="mt-1 text-sm text-muted-foreground">Halt every running agent session at once. The gate stays open — you can launch new runs after.</p>
</div>
<Button size="sm" variant="destructive" className="shrink-0 gap-1.5" disabled={busy} onClick={stopAll}>
<Square className="h-3.5 w-3.5" /> {busy ? 'Stopping…' : 'Stop all'}
</Button>
</div>
{hint && <p className="text-sm text-muted-foreground">{hint}</p>}
</CardContent>
</Card>
)
}

/**
* Settings → System → Software — version + self-update + restart. Polls `/api/update` (a cached
* `git fetch`): shows the running build, whether the checkout is behind origin, and — for the owner —
Expand Down
13 changes: 13 additions & 0 deletions web/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,15 @@ export interface TeamResp {
identities: Record<string, MemberIdentity[]>
agents: AgentInfo[]
}
/** Host resource snapshot (GET /api/system). Bytes for memory; fractions 0–1 for percentages. */
export interface SystemMetrics {
mem: { total: number; free: number; used: number; usedPct: number }
cpu: { count: number; model: string; usagePct: number; loadAvg: number[] }
process: { rss: number; heapUsed: number; heapTotal: number; uptime: number }
host: { platform: string; arch: string; release: string; hostname: string; uptime: number }
runningSessions: number
error?: string
}
export interface Session {
id: string
agent: string
Expand Down Expand Up @@ -892,6 +901,10 @@ export const api = {
messages: (scope: 'mine' | 'all' = 'mine') => call<Msg[]>('GET', `/api/messages${scope === 'all' ? '?scope=all' : ''}`),
run: (agent: string, task: string) => call<{ id: string; tmux: string; error?: string }>('POST', '/api/sessions', { agent, task }),
stopSession: (id: string) => call<{ ok: boolean; error?: string }>('POST', `/api/sessions/${id}/stop`),
/** Halt every running session tenant-wide (owner/admin). Softer sibling of the kill switch. */
stopAllSessions: () => call<{ ok: boolean; halted?: number; error?: string }>('POST', '/api/sessions/stop-all'),
/** Host resource snapshot for Settings → System (RAM / CPU / uptime). */
system: () => call<SystemMetrics>('GET', '/api/system'),
rateSession: (id: string, rating: 'up' | 'down' | null) => call<{ ok: boolean; error?: string }>('POST', `/api/sessions/${id}/rate`, { rating }),
/** Lift the stop-block so a stopped session resurrects (claude --resume) on the next terminal open. */
resumeSession: (id: string) => call<{ ok: boolean; error?: string }>('POST', `/api/sessions/${id}/resume`),
Expand Down
Loading