diff --git a/CHANGELOG.md b/CHANGELOG.md index f33c790..e700e66 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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; diff --git a/package-lock.json b/package-lock.json index 078142d..5edade1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "agent-os", - "version": "0.140.0", + "version": "0.141.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "agent-os", - "version": "0.140.0", + "version": "0.141.0", "license": "MIT", "bin": { "agent-os": "bin/agent-os" diff --git a/package.json b/package.json index 04cd523..bb65de7 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/server.ts b/src/server.ts index 02eca2e..02a6923 100644 --- a/src/server.ts +++ b/src/server.ts @@ -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'; @@ -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> { + 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 { @@ -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' }); diff --git a/web/src/App.tsx b/web/src/App.tsx index 387dbae..dd1d494 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -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' @@ -7989,6 +7989,8 @@ function SystemSettings({ state, me }: { state: StateResp | null; me: Member }) return (
+ +

@@ -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 ( +

+
+
+ ) +} + +/** + * 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(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 ( + + +
Host resources
+ {err ?

{err}

+ : !m ?

Reading…

+ : ( + <> +
+
+ Memory + {fmtGB(m.mem.used)} / {fmtGB(m.mem.total)} · {Math.round(m.mem.usedPct * 100)}% +
+ +

{fmtGB(m.mem.free)} free

+
+
+
+ CPU + {Math.round(m.cpu.usagePct * 100)}% · {m.cpu.count} cores +
+ +

+ {m.cpu.model}{m.cpu.loadAvg.some((n) => n > 0) ? ` · load ${m.cpu.loadAvg.map((n) => n.toFixed(2)).join(' ')}` : ''} +

+
+
+ {([ + ['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]) => ( +
+
{label}
+
{value}
+
+ ))} +
+ + )} +
+
+ ) +} + +/** + * 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 ( + + +
+
+
Stop all sessions
+

Halt every running agent session at once. The gate stays open — you can launch new runs after.

+
+ +
+ {hint &&

{hint}

} +
+
+ ) +} + /** * 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 — diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 371335e..a689bb7 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -112,6 +112,15 @@ export interface TeamResp { identities: Record 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 @@ -892,6 +901,10 @@ export const api = { messages: (scope: 'mine' | 'all' = 'mine') => call('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('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`),