Cerberus is Carino Systems’ self-hosted LLM runtime. It has three parts, which give it its name:
Cerberus is a service plus a desktop client. The service is the Go binary cerberus. The desktop
base is the existing Atrium LLM chat (llm-chat.py), which talks to Cerberus as an OpenAI-compatible
server. The desktop client gets its own phase later.
Linux comes first. macOS and Windows are future targets, so the code must cross-compile today: no cgo, and platform-specific code goes behind build tags, with stubs where a platform isn’t supported yet.
llama-server), LM Studio
(lms), Ollama and, later, vLLM.main in the main checkout.github.com/creack/pty. Vendored front-end
assets (xterm.js) are self-hosted, never loaded from a CDN.| Phase | Contents |
|---|---|
| 1 (this) | Model hosting and gateway; agent sessions with worktrees, test gates, previews, a resource guard and queue, escalation and remote approval; web UI; CLI; systemd user unit |
| 2 | Usage and limit tracking per subscription, searchable session history and resume, voice tasks (local Whisper), secrets vault, fleet-wide tasks (one instruction → one session per repo) |
| 3 | GPU power-cap auto-tuner (tokens per joule), speculative-decoding and quantisation presets per GPU, vLLM backend, more than one GPU per box, several hosts (agent mode plus gateway mode) |
| 4 (client edition) | Plain chat for non-developers (Spanish first), RAG over documents, several users with roles, budgets and audit log (LFPDPPP), fully offline, Carino remote support (health alerts, updates, opt-in support tunnel) |
| 5 (desktop) | Cerberus desktop client grown from Atrium LLM chat, dynamic-island agent status, launcher “new task”, bar readout of GPU power and tok/s; macOS and Windows builds |
Phase 1 runs on one host, but the data model already holds a host with a list of GPUs, so several GPUs and several hosts don’t need a rewrite later.
Module path: carino.systems/cerberus. Binary: cerberus. Go 1.26 or later. CGO_ENABLED=0. These
must all build: GOOS=linux, GOOS=darwin and GOOS=windows (go build ./..., go vet ./...).
cmd/cerberus/ main + subcommands (serve, agent, status, task, url, install-service, version)
internal/config/ Config types, defaults, load/save, token generation (FROZEN — see config.go)
internal/platform/ paths per OS, process-group kill, meminfo, PTY (build tags)
internal/gpu/ GPU monitor (nvidia-smi, AMD sysfs, none) + energy integrator
internal/engine/ model backends + manager (load/unload/idle TTL/make room)
internal/usage/ usage records (JSONL) + aggregates
internal/gateway/ OpenAI-compatible /v1 proxy with streaming + metrics
internal/session/ agent sessions: PTY, scrollback, worktrees, checks, preview, queue/guard, escalation, accept
internal/agent/ built-in headless coding agent (tool loop) run as `cerberus agent`
internal/notify/ notifications (command hook, ntfy, notify-send)
internal/api/ /api/* JSON + SSE, auth, preview mount, embeds web/
web/ the web UI (embedded with go:embed via web/embed.go)
packaging/ cerberus.service (systemd user unit)
platform)os.UserConfigDir()/cerberus/config.json (on Linux, ~/.config/cerberus/config.json). It’s
created with defaults and a random token on first run, mode 0600.$XDG_DATA_HOME or ~/.local/share/cerberus; macOS ~/Library/Application Support/Cerberus;
Windows %LOCALAPPDATA%\Cerberus. Inside it:
sessions/<id>/meta.json and sessions/<id>/scrollback.log (capped, ScrollbackMiB)worktrees/<id>/ (the detached worktree)usage.jsonllogs/<model-id>.log (backend process output)internal/gpu)type GPU struct { Index int; Name string; Vendor string; MemTotalMiB, MemUsedMiB int; PowerW, PowerLimitW float64; UtilPct int; TempC int; PState string }
type Snapshot struct { Time time.Time; GPUs []GPU; EnergyJ float64 /* cumulative since start, all GPUs */ }
type Monitor struct{ ... }
func New(cfg config.GPUConfig, log *slog.Logger) *Monitor
func (m *Monitor) Run(ctx context.Context) // poll loop
func (m *Monitor) Snapshot() Snapshot // idle: re-queried on demand when older than IdleIntervalMs
func (m *Monitor) SetActive(active bool) // poll every ActiveIntervalMs when true; no polling when false
func (m *Monitor) Refresh(ctx context.Context) // query now (before make-room decisions)
func (m *Monitor) EnergyJ() float64 // cumulative joules (trapezoid), extrapolated from the last sample
func (m *Monitor) FreeVRAMMiB() int // sum free; -1 if unknown (idle: on demand like Snapshot)
Backends: NVIDIA via nvidia-smi --query-gpu=index,name,memory.total,memory.used,power.draw,power.limit,utilization.gpu,temperature.gpu,pstate --format=csv,noheader,nounits; AMD via
/sys/class/drm/card*/device/{mem_info_vram_total,mem_info_vram_used,gpu_busy_percent,hwmon/hwmon*/power1_average}; otherwise none.
Run one query per tick, never a resident -lms process. A missing tool is not an error: the result is an empty snapshot.
internal/engine)type ModelInfo struct { ID, Backend, Name string; Local, Loaded bool; SizeMiB int; Ctx int; LastUsed time.Time; InFlight int; Vision bool }
type Endpoint struct { BaseURL string /* ends in /v1 */; Model string /* id the backend expects */; Headers map[string]string; Local bool; Backend string }
type Backend interface {
Name() string // "llamacpp" | "lmstudio" | "ollama" | "openai"
Available() bool
List(ctx context.Context) ([]ModelInfo, error)
Load(ctx context.Context, m ModelInfo) (Endpoint, error) // idempotent
Unload(ctx context.Context, m ModelInfo) error
Endpoint(m ModelInfo) Endpoint
}
type Manager struct{ ... }
func NewManager(cfg *config.Config, gpu *gpu.Monitor, log *slog.Logger) *Manager
func (m *Manager) Run(ctx context.Context) // idle reaper (per-model TTL), refresh lists, stops spawned processes on ctx done
func (m *Manager) Models(ctx context.Context) []ModelInfo // cached ≤5 s
func (m *Manager) Acquire(ctx context.Context, id string) (Endpoint, func(), error) // load if needed (making room), InFlight++; release func: InFlight--, LastUsed=now
func (m *Manager) Load(ctx context.Context, id string) error
func (m *Manager) Unload(ctx context.Context, id string) error
func (m *Manager) Shutdown(ctx context.Context)
ModelConfig entries with Path. Spawns cfg.Engines.LlamaCpp.Binary -m PATH --host 127.0.0.1 --port <free> -c CTX -np PARALLEL --jinja --flash-attn on --metrics plus Args, then waits for GET /health to return 200 (timeout LoadTimeoutSec). Process output goes to logs/<id>.log. Unload sends SIGTERM to the process group, then SIGKILL after 10 s. Also scans ModelDirs for *.gguf (id = file stem, mmproj files skipped).lms ls --json, lms ps --json, lms load <key> --context-length N --ttl S -y --identifier <key>, lms unload <key>, lms server start if GET {base}/models fails. Base from config (default http://127.0.0.1:1234/v1).GET /api/tags, GET /api/ps; load = POST /api/generate {"model":M,"keep_alive":"<ttl>"}; unload = keep_alive:0; OpenAI base {url}/v1.ProviderConfig. Model IDs are <provider>/<model>. Models come from config or from GET {base}/models, cached for 10 min. The key comes from KeyEnv, or else from secrets.json (in the config dir, mode 0600, {"<provider>":"key"}). Load and Unload do nothing.gpu.FreeVRAMMiB() is known and below SizeMiB*1.15 + 512, unload idle local models (InFlight==0, least recently used first) until there is room. If it still won’t fit, try anyway when Engines.AllowOverCommit is set, otherwise return an error.now-LastUsed > TTL gets unloaded. The TTL comes from the model, else Engines.IdleTTLSec. It calls gpu.SetActive(anyLoaded || anyInFlight).default → cfg.Engines.DefaultModel.internal/usage)type Record struct { Time time.Time; Model, Backend, Client string; Local bool; PromptTokens, CompletionTokens int; TTFTMs, DurationMs int64; TokPerSec float64; EnergyJ float64; TokPerJ float64; Status int; Error string; SessionID string }
type Store struct{ ... }
func Open(path string, keep int) (*Store, error) // appends JSONL; keeps last `keep` in memory
func (s *Store) Add(r Record)
func (s *Store) Recent(n int) []Record
type Agg struct { Model string; Requests, PromptTokens, CompletionTokens int; EnergyJ, AvgTokPerSec, AvgTokPerJ float64 }
func (s *Store) Aggregate(since time.Time) []Agg
func (s *Store) Close() error
internal/gateway)func New(eng *engine.Manager, mon *gpu.Monitor, store *usage.Store, log *slog.Logger) *Gateway
func (g *Gateway) Handler() http.Handler // mounted at /v1 by api (paths below are relative to /v1)
GET /models returns the OpenAI list format with every engine.Models, including owned_by: backend.POST /chat/completions, POST /completions, POST /embeddings: read the body, Acquire(model),
rewrite model to Endpoint.Model, and for streaming requests set stream_options.include_usage=true.
Then proxy to Endpoint.BaseURL + path with its headers, flushing every SSE line as it arrives, and
propagate client cancellation upstream. It measures TTFT (time to the first content byte) and token
counts (from usage, or else the number of content deltas). Energy is gpu.EnergyJ() delta ÷
concurrent local in-flight requests, local models only. It writes a usage.Record, with Client
taken from the X-Cerberus-Client header and SessionID from X-Cerberus-Session.{"error":{"message":..,"type":..}}. An unknown model returns 404.internal/session)States: queued → starting → running ⇄ waiting → checking → passed | failed → (retrying | escalated) …, with terminal states accepted | discarded | stopped | error.
waiting means the agent asked for input (the OSC marker below), or produced no output for IdleWaitSec while still running (interactive CLIs).
When the process exits, checks run if the repo has any. With no checks configured, exit code 0 → passed.
type TaskSpec struct {
Repo string // absolute path of a git repo (must be in cfg.Repos or under cfg.RepoRoots)
Task string // the instruction
Agent string // AgentProfile name (default cfg.DefaultAgent)
Model string // model id for agents that take one (default engine default)
EscalateTo string // profile to hand over to after MaxAttempts failed check runs ("" = none)
MaxAttempts int // attempts with Agent before escalating (default cfg.Sessions.MaxAttempts)
Count int // fan-out: N independent sessions with the same task (default 1), each its own worktree
Title string
}
type Info struct {
ID, Title, Repo, Task, Agent, Model, EscalateTo string
State string; Attempt int; ExitCode *int; Created, Updated time.Time
Worktree, BaseCommit string; ChecksOutput string; PreviewURL string; Error string; Group string /* fan-out group id */
WaitingReason string; Cols, Rows int
}
type Event struct { Type string /* "state" | "created" | "removed" */; Session Info }
type Manager struct{ ... }
func NewManager(cfg *config.Config, eng *engine.Manager, gpu *gpu.Monitor, notifier *notify.Notifier, log *slog.Logger) (*Manager, error) // loads existing sessions from disk (live ones become "stopped", reason "daemon restarted"; worktree kept)
func (m *Manager) Run(ctx context.Context) // queue scheduler (every 2 s + on signal)
func (m *Manager) Create(ctx context.Context, t TaskSpec) ([]Info, error)
func (m *Manager) List() []Info
func (m *Manager) Get(id string) (Info, bool)
func (m *Manager) Input(id string, data []byte) error
func (m *Manager) Resize(id string, cols, rows int) error
func (m *Manager) Attach(id string) (backlog []byte, ch <-chan []byte, detach func(), err error) // backlog = last ScrollbackTailKiB of output
func (m *Manager) FollowUp(ctx context.Context, id, text string) error // running → typed into the PTY + "\r"; finished → new attempt of the same agent in the same worktree with text as the task
func (m *Manager) RunChecks(ctx context.Context, id string) error
func (m *Manager) Stop(id string) error
func (m *Manager) Diff(id string) (string, error) // unified diff of worktree vs BaseCommit, including untracked files
func (m *Manager) Accept(ctx context.Context, id string, o AcceptOpts) (string /*commit sha*/, error)
func (m *Manager) Discard(id string) error // kill, git worktree remove --force, state discarded
func (m *Manager) Subscribe() (<-chan Event, func())
func (m *Manager) Shutdown(ctx context.Context)
type AcceptOpts struct { Message string; Push bool }
git -C REPO worktree add --detach DATA/worktrees/<id> <HEAD of main>. No branch is ever created. BaseCommit is recorded.git add -A; if there are changes, commit, detached, with the identity from
cfg.Git (falling back to the repo’s git config). The message is o.Message, or else the session title.
There are never attribution trailers. Then rebase the worktree’s commits onto the repo’s current main
(detached git rebase), squash them to a single commit (git reset --soft <main> + commit), and in the
main checkout run git merge --ff-only <sha>. The main checkout must be on main, otherwise refuse
with a clear message. Git itself protects uncommitted conflicting changes, and that error is surfaced.
Then git push origin main if o.Push. Then remove the worktree and set the state to accepted.
On any failure, nothing is lost: the worktree stays and the error is returned.platform.StartPTY(cmd, cols, rows). The child runs in its own process group with environment
CERBERUS_SESSION=<id>, CERBERUS_URL, OPENAI_BASE_URL=http://127.0.0.1:<port>/v1,
CERBERUS_REPO=<repo>, OPENAI_API_KEY=<cfg.AgentToken()> (valid for /v1 only, never the admin
token), TERM=xterm-256color. When the agent exits, or 10 s after a stop, every process left in its
session is killed. When Guard.SystemdSlice is set and systemd-run is on PATH
(Linux), the command is wrapped as systemd-run --user --scope --quiet --slice=<slice> -p MemoryMax=<Guard.SessionMemoryMax> -- cmd….config.AgentProfile): Command []string with placeholders {task}, {task_file},
{model}, {base_url}, {cwd}, {session}. The task is also written to sessions/<id>/task.md. Built-in
defaults (see config defaults): cerberus (the built-in agent), claude (interactive), claude-headless,
opencode, aider, shell. A profile whose binary isn’t on PATH is listed as unavailable.cerberus agent, the agent itself loads context (see below). For other profiles,
nothing is injected, since they read CLAUDE.md or AGENTS.md natively.ESC ] 777 ; cerberus ; <kind> [; text] BEL with kind waiting
(text = reason), resumed or done. The session manager strips these from the stream and changes state.RepoConfig.Checks (each a shell command run with bash -c in the worktree,
ChecksTimeoutSec), or, if the repo has no config entry, from <worktree>/.cerberus.json {"checks":[...],"preview":{...}}.
The combined output is clipped to 64 KiB and goes into ChecksOutput.Attempt < MaxAttempts → a new attempt of the same agent in the
same worktree, with the task “The checks failed. Fix the problem. Original task: … Check output: …”.
Checks fail and attempts are exhausted with EscalateTo set → a new attempt with the EscalateTo profile
(state escalated → running), with the original task + the check output + “a previous agent left work
in this tree; review it”.RepoConfig.Preview or .cerberus.json preview {"dir":"."} → served by api at
/preview/<id>/ as static files from the worktree subdir (index.html, no directory listing beyond a
simple list). PreviewURL = /preview/<id>/, which redirects (authenticated) to
/preview/<id>/~<cap>/ where cap is a per-session capability; preview pages are served with
Content-Security-Policy: sandbox allow-scripts … (opaque origin, no access to /api).Guard.MaxSessions, MemAvailable ≥
Guard.MinFreeMiB (platform.MemAvailableMiB; unknown → allowed) and, for a local model, free VRAM is
unknown or ≥ the model’s estimate or the model is already loaded. Otherwise it stays queued with
WaitingReason explaining why.waiting, passed, failed, escalated or error, call notifier.Send(...).internal/agent, run as cerberus agent)cerberus agent --base URL --model M --task-file F --cwd DIR [--key K] [--session ID] [--max-steps N] [--approve auto|ask]
read_file, list_dir, search, run_command,
write_file and edit_file, matching the semantics of Atrium’s llm_chat_engine.py (clipping limits,
exact-once edit). If the server rejects tools, it falls back to plain chat.AGENTS.md and
CLAUDE.md from the repo root (if present). It also includes each file listed in
cfg.Context.Files (for example the memory index), each clipped to cfg.Context.MaxBytesPerFile.▸ run_command: npm test) with a clipped result.--approve ask (the default from config Agent.Approve) asks before run_command,
write_file and edit_file. The agent prints the request, emits OSC 777;cerberus;waiting;approval,
then reads a line from stdin: y runs it, a runs it and allows that tool for the rest of the
session, n refuses it (the model is told it was refused), and any other text refuses it and is sent
to the model as the reason. After the answer it emits resumed. Paths matching cfg.Agent.DenyPaths
globs are always refused, and paths outside --cwd are refused for writes.done and exits 0. It exits 2 on model or server errors.internal/notify)type Message struct { Title, Body, Urgency string /* low|normal|critical */; SessionID string; URL string }
func New(cfg config.NotifyConfig, log *slog.Logger) *Notifier
func (n *Notifier) Send(msg Message) // non-blocking; never fails the caller
Channels, all optional: Command (argv, with {title}, {body} and {urgency} substituted; the default
is Atrium’s ~/.config/hypr/scripts/notify when it exists), NtfyURL (a POST with a Title header), and
notify-send as a fallback when Command is empty and the binary exists. Sends are rate-limited per
session (at most one every 10 s per state).
internal/api)type Deps struct { Cfg *config.Config; Engine *engine.Manager; GPU *gpu.Monitor; Usage *usage.Store; Gateway *gateway.Gateway; Sessions *session.Manager; Log *slog.Logger; Version string }
func New(d Deps) http.Handler
Authorization: Bearer <token>, a cookie cerberus_token, or ?token= on GET (which sets the
cookie and redirects without it). Comparisons are constant-time. /v1/* from a loopback address needs
no auth when cfg.Server.LocalNoAuth is set, so desktop clients work with no key, unless it looks like
a web page sent it (an Origin header, a non-local Host, or a write without a JSON Content-Type).
cfg.AgentToken() works for /v1/* only. Cookie-authenticated /api/* requests from another site or
a preview page are refused. Everything else always needs auth, except /login, /assets/*,
/healthz and preview capability URLs.GET /healthz → {"ok":true}GET /api/status → {version, host, uptime, gpu: Snapshot, models: [ModelInfo], sessions: {counts}, guard: {mem_available_mib, max_sessions, running}, agents: [{name, available}], repos: [...]}GET /api/models, POST /api/models/{id}/load, POST /api/models/{id}/unload (the id is path-escaped and may contain /)GET /api/usage?limit=100 → {recent: [Record], agg_24h: [Agg]} (agg_24h from hourly totals, not the recent ring)GET /api/repos → configured repos plus git repos found one level under each RepoRoots entry: [{name, path, checks: bool, preview: bool}]GET /api/sessions, POST /api/sessions (TaskSpec JSON) → [Info]GET /api/sessions/{id}, GET /api/sessions/{id}/diff → text/plainGET /api/sessions/{id}/stream → SSE: first event: backlog then event: out, data = base64 bytes; a : ping every 20 sPOST /api/sessions/{id}/input {"data":"..."}, /resize {"cols":..,"rows":..}, /followup {"text":..},
/checks, /stop, /accept {"message":..,"push":bool}, /discardGET /api/events → SSE of session Events (event: session)/v1/ → gateway. /preview/<id>/… → static files of the session’s preview dir./ → the embedded web UI (web.FS), with Cache-Control: no-cache for index.html.web/)web/embed.go
(//go:embed all:dist or the file list) and exposing var FS embed.FS.~/Github/Branding/carino-ui.css, copied
into web/ together with the needed self-hosted fonts). The top header follows the fleet navbar
convention: the brand “Carino” + app tag “Cerberus”, with no footer.web/vendor/), an input bar
with quick keys (Enter, y, n, a, Esc, Ctrl-C, ↑, ↓, Tab), state and attempt, and buttons for Run
checks, Stop, Diff, Preview, Accept (message and push toggle) and Discard. The diff view lets you
tap a line to comment, and “Send comments” posts them as one follow-up.?token=).cmd/cerberus)cerberus serve: load config, start gpu, engine, sessions, usage and api; serve on Server.Listen
and each Server.ExtraListen; on SIGTERM, shut down gracefully (stop sessions, unload spawned models).cerberus agent …: the built-in agent (above).cerberus status: a short human summary, read from GET /api/status.cerberus task [--repo DIR] [--agent A] [--model M] [--escalate P] [--count N] "task…": POST /api/sessions.cerberus url: prints the UI URL with ?token= for every listen address (for pairing a phone).cerberus install-service: writes ~/.config/systemd/user/cerberus.service from packaging/
(embedded), then systemctl --user daemon-reload && systemctl --user enable --now cerberus (Linux only;
elsewhere it prints “not supported yet”).cerberus version.server_url to http://127.0.0.1:7340/v1 in
llm.json (LocalNoAuth covers the key).scripts/notify when present.docs/DESKTOP.md describes this and the Phase 5 plan.go build ./... and go vet ./... pass for linux, darwin and windows; go test -race ./... passes on Linux.git branch --list shows only main).cerberus serve with no models under 30 MB; idle CPU about 0%.