# Host metrics agent — `3pm hostmetrics` from a separate, cached process

`3pm hostmetrics` reports the whole box (CPU incl. steal, PSI, memory,
disks, RAID, network, TCP health, sensors, GPUs, batteries, NTP offset — field list
in `metrics.md`). Since the host agent landed, **none of that is
collected by the daemon**: a dedicated process samples it on timers —
one per cost tier, see *Sampling tiers* below and `hostagent-tiers.md` —
and the daemon only relays the latest assembled snapshot. Long-form
rationale: `FEATURES.md` → *Host metrics agent*; internals:
OV `3pm-docs-archived/architecture` §8.2.

## Why a separate process

Host collection is the code most exposed to the outside world: `/proc`
parsers, `sysinfo`/`systemstat`, a UDP NTP probe, `nvidia-smi` and
`ioreg` subprocesses, per-vendor GPU quirks. A panic, a memory leak or a
hang there must never reach the supervisor that owns your apps. The
agent is that blast wall: it can die, leak or wedge, and the worst
outcome is `3pm hostmetrics` reporting `unavailable` until the daemon has
respawned it.

Two side effects you get for free:

- **Instant answers.** The command reads a cache: ~10 ms instead of the
  200–700 ms a live collection needs (CPU% is a delta over a warmup
  window, plus the NTP round trip). The daemon forwards the agent's
  reply line as it came (`hostagent/proxy.rs`): it looks at the tag the
  line opens on, never decodes the ~50 KB snapshot, and does not encode
  it again — save for a hosted user's copy, which is decoded to be
  scrubbed (`Caller::scrub_host`).
- **A steady sampling cadence**, which is what a fleet/monitoring layer
  wants to pull anyway.

## What it looks like

```
3pm (CLI) ──rpc.sock──▶ 3pm __daemon ──host.sock──▶ 3pm __host-agent
                          │ spawn / respawn / kill        │ fast 1 s / medium 10 s / slow 5 min → cache
                          └── Request::HostMetrics = proxy └── serves ping + host_metrics
```

```console
$ 3pm ls
┌────┬──────┬─────┬────────┬───┬────────┬──────────┬─────┬─────┬───────┐
│ id ┆ name ┆ pid ┆ uptime ┆ ↺ ┆ status ┆ watching ┆ cpu ┆ mem ┆ ports │
…
host metrics | cpu: 38.8% 62.8º | load: 7.42 | ram: 63.5% | gpu: 22% 1199/11538mb | en0: ⇓ 1.2mb/s ⇑ 300kb/s | disk: ⇓ 512kb/s ⇑ 2.0mb/s

$ 3pm hostmetrics | head -4
 box — Linux 6.8 (x86_64)
┌─────────┬───────────────────────────────────┐
│ sampled ┆ 2026-08-28T15:02:48Z (3s ago)     │
│ uptime  ┆ 48D                               │

$ 3pm hostmetrics --json | jq .collected_at_epoch_ms
1787490168000

$ 3pm internals | grep 'host agent'
│ host agent  ┆ pid 50632, 0 restarts │

$ 3pm internals | tail -6
 internal processes
┌────────────┬──────────────────┬───────┬────────┬────────┬──────┬────────┬───┐
│ role       ┆ command          ┆ pid   ┆ status ┆ uptime ┆ cpu  ┆ mem    ┆ ↺ │
│ daemon     ┆ 3pm __daemon     ┆ 50631 ┆ online ┆ 2D     ┆ 0.4% ┆ 26.7mb ┆ 0 │
│ host-agent ┆ 3pm __host-agent ┆ 50632 ┆ online ┆ 2D     ┆ 0.2% ┆ 35.9mb ┆ 0 │
└────────────┴──────────────────┴───────┴────────┴────────┴──────┴────────┴───┘

$ ps -o pid,ppid,command | grep 3pm
50631     1 3pm __daemon
50632 50631 3pm __host-agent
```

The `host metrics` line under `3pm ls` (pm2 parity; also printed by
`start`/`stop`/`restart`/`delete`/`scale`, which reprint the table) is
the same cache read under a 500 ms budget: cpu (+ hottest CPU sensor),
load 1m, ram %, gpu (utilization, memory, temperature when a GPU
reports), one entry per physical NIC carrying traffic, disk throughput
plus any mount over 60 % full. Colors follow pm2's thresholds. Absence
of the line is never an error (agent down, no sample yet, wedged);
`ls --json` stays the bare records array. Throughput segments need two
fast samples — they show up from the second tick on (~2 s).

The `sampled` row (and `collected_at_epoch_ms` in `--json`) tells you
how old the cheap gauges are; `sampled (medium)` / `sampled (slow)`
rows appear only when a slower tier lags it by more than 2 s
(`sampled_at_epoch_ms.{fast,medium,slow}` in `--json`). `host agent` in
`3pm internals` tells you the agent's pid and how many times the daemon
had to respawn it since it started; its `internal processes` table adds
the agent's uptime, cpu and RSS next to the daemon's (`internal_processes`
in `--json`) — the place to watch a long-lived agent for a leak. The
agent's cpu/mem ride the daemon's 1 s metrics tick and are attributed
only while the sampled pid is the current one, so a fresh incarnation
starts at 0 instead of inheriting its predecessor's numbers; between
incarnations the row reads `waiting restart` with no pid.

## Sampling tiers

Collectors are grouped by cost, each on its own ticker and blocking
thread (`hostagent/sampler.rs`), so a hung `nvidia-smi` or a stalled
network mount never delays CPU/RAM (`docs/hostagent-tiers.md` has the
audit behind the split):

| Tier | Period (knob) | What |
|---|---|---|
| fast | 1 s (`THREEPM_HOSTMETRICS_INTERVAL_MS`) | cpu %/per-core/states, kernel, load, uptime, memory/swap, paging, meminfo, PSI, file handles, sockets, TCP counters, conntrack, cgroup, VFS cache, entropy, NIC counters + rates, block devices, per-mount I/O + rates |
| medium | 10 s (`THREEPM_HOSTMETRICS_MEDIUM_MS`) | process table, TCP states (socket table), sensors, GPUs, mount space + inodes (`statvfs`), RAID, batteries (`/sys/class/power_supply`, Linux), macOS `vm_stat`/`netstat`, logged-in accounts (`sessions`, one utmp walk) |
| slow | 5 min (`THREEPM_HOSTMETRICS_SLOW_MS`) | NTP offset; host identity re-read (hostname, OS, kernel, CPU model/cores, the account the daemon runs as) |
| 3pm | = medium | 3pm's own footprint (`3pmDaemon`, below): the daemon and the agent read from the OS, the daemon's counters pulled over `rpc.sock` |

The fast collector is stateful (keeps its `sysinfo` handles and the
previous CPU ticks): the tick is the CPU-% window, so only the very
first sample pays a 200 ms warmup. A snapshot is served once identity,
fast and medium have each landed once; the slow tier (up to 500 ms of
NTP) is not waited for.

## 3pm's own metrics (`3pmDaemon`)

The snapshot also reports what 3pm itself costs the box, collected by
the agent like everything else — **not** self-reported by the daemon —
on a tier of its own (`hostagent/selfmetrics.rs`, medium period) from
two sources kept apart so either can fail alone:

- **The OS.** One row per 3pm process — the daemon (the agent's parent
  pid, the one `watchdog.rs` already polls) and the agent itself — cpu %
  over the tier's window, RSS, uptime, open descriptors and threads
  (`hostmetrics/threepm.rs`). A process the kernel no longer knows is
  left out, never reported at 0. Descriptors and threads of a *foreign*
  pid are Linux-only (`/proc/<pid>/fd`, `/proc/<pid>/status`); on macOS
  the daemon row's `open_fds` comes from the daemon's own count instead
  and `threads` is absent.
- **The daemon.** `Request::Internals` on `rpc.sock` — the counters
  that exist nowhere else (apps by status, restarts, timers, bus
  subscribers, log sinks/rotations/bytes, `RLIMIT_NOFILE`, handoffs,
  agent respawns), reduced to the health subset `ThreepmRuntime`
  (nothing about paths or configuration: the snapshot rides the fleet
  link). One connection per tick — `connect_at` (ping + protocol check,
  never a spawn) then the request — bounded to 2 s in total. A dead,
  wedged, handing-off (`3pm update`) or version-skewed daemon blanks
  `runtime` for that tick while the rows keep coming; the previous part
  is served meanwhile, and the command never fails for it.

`3pm hostmetrics` prints the two tables after the sensors; `--json`
carries `3pmDaemon: {at_epoch_ms, processes[], runtime?}`. The section
is optional on the wire and never gates the first snapshot (identity +
fast + medium still do), so a slow daemon cannot delay `hostmetrics`.
The daemon's own 1 s self-sample (`3pm internals` → `internal
processes`) is unchanged: `internals` does not depend on the agent.

```console
$ 3pm hostmetrics | tail -22
 3pm processes
┌────────────┬───────┬────────┬──────┬────────┬─────┬─────────┐
│ role       ┆ pid   ┆ uptime ┆ cpu  ┆ mem    ┆ fds ┆ threads │
│ daemon     ┆ 50631 ┆ 2D     ┆ 0.4% ┆ 26.7mb ┆ 41  ┆ 9       │
│ host-agent ┆ 50632 ┆ 2D     ┆ 0.2% ┆ 35.9mb ┆ 18  ┆ 7       │
└────────────┴───────┴────────┴──────┴────────┴─────┴─────────┘

 3pm runtime
┌────────────┬─────────────────────────────────────────────────────┐
│ daemon     ┆ v0.9.3, up 2D, boot 0x1f3a, 1 handoff               │
│ apps       ┆ 12 (11 online, 1 errored)                           │
│ restarts   ┆ 37 (2 unstable)                                     │
│ timers/bus ┆ 4 armed, 1 subscriber                               │
│ logs       ┆ 12 sinks, 3 rotations, 1.1gb written; 3pm.log 4.0kb │
│ nofile     ┆ 1024 / 1048576                                      │
│ host agent ┆ 0 restarts                                          │
│ sampled    ┆ 2026-08-28T15:02:40Z (11s ago)                      │
└────────────┴─────────────────────────────────────────────────────┘
$ 3pm hostmetrics --json | jq '."3pmDaemon".runtime.apps_total'
12
```

## Extended Monitoring (`3pm hostmetrics --extended`)

With `[hostmetrics] extended` on (the default, below), the snapshot's
`extended` map carries the kernel's own counters. `3pm hostmetrics`
leaves them out — a Linux box has ~1 200 — and `--extended` prints them
**instead of** the overview (the typed view is one command away and
would otherwise scroll out of reach): the host banner, a count, one table
per family and last the sources that could not be read.

```console
$ 3pm hostmetrics --extended | grep ' — '
 labomatic — Ubuntu Linux (Ubuntu 20.04) (5.4.0-153-generic, x86_64)
 extended monitoring — 23 families, 1228 values
 cpu — counters since boot, 32 rows
 softirqs — counters since boot
 meminfo — gauges
 vmstat — counters since boot
 vmstat_gauge — gauges
 …
 cpuidle — counters since boot, 96 rows
 cpufreq — gauges, 31 rows
 thermal — gauges, 32 rows
$ 3pm hostmetrics --extended | tail -6     # the agent runs as ubuntu, not root
 unavailable
┌────────────┬──────────┐
│ family     │ reason   │
├────────────┼──────────┤
│ shortlived │ not_root │
└────────────┴──────────┘
```

The same host with a root agent has no `unavailable` table and three
more families, `shortlived`, `shortlived_by_comm` and
`shortlived_by_comm_max`, last (the table does not name them).

- **Order**: the collection table's (`hostmetrics/extended/table.rs`,
  `EXTENDED` then `SLOW`, exported as
  `threepm_daemon::host_extended_family_order`), each `<family>_gauge`
  right after its family, then any family the table does not name
  (a newer daemon, eBPF's `shortlived*`) alphabetically. Rows sort
  naturally (`2` before `10`, `0/state2` before `0/state10`).
- **Values**: `meminfo` in bytes (its `HugePages_*` lines are page
  counts and stay numbers), whole numbers plainly, fractions to three
  decimals; every other unit is the kernel's own (µs, kHz, m°C, USER_HZ
  ticks). Nothing is summarized, nothing hidden: all-zero rows print.
- **`unavailable`**: a closing `family | reason` table (`permission`,
  `error`, and eBPF's `not_root` / `bpf_disabled` / `load_failed`); a
  family present but only partly read says `(partial: <reason>)` in its
  title.
- **Mode off** (`extended` absent once the slow tier has landed, since
  with the mode on that tier always carries the map): one line,
  `extended monitoring is off`. **Not yet** (`extended` absent and no
  `sampled_at_epoch_ms.slow`: the agent just started or respawned, or
  the daemon predates Extended Monitoring): `extended metrics not
  collected yet`. **macOS** (present, empty): `no extended source` on
  this platform.
- `--json` is the whole snapshot either way, `extended` included.
- **Short-lived processes** (Linux, the agent running as root): the
  eBPF families `shortlived` (every exec and exit since the agent
  started, and how many lived under a second), `shortlived_by_comm`
  (exits and lifetime per process name, the window's 10 busiest names
  and `other`; processes whose exec was seen, so a fork-only worker is
  in `shortlived` alone) and `shortlived_by_comm_max` (the window's longest life
  per name). An agent without the capabilities (`CAP_SYS_ADMIN`, or
  `CAP_BPF` + `CAP_PERFMON`, read from `CapEff` before any load) lists
  `shortlived | not_root` under `unavailable`; macOS says nothing. Those
  capabilities are necessary, not sufficient: attaching also reads
  tracefs (`/sys/kernel/tracing/events/…/id`, often `0700` root: uid 0
  or `CAP_DAC_READ_SEARCH`), Ubuntu's `perf_event_paranoid = 3` may
  refuse `perf_event_open` without `CAP_SYS_ADMIN`, and before Linux
  5.11 the maps count against `RLIMIT_MEMLOCK` — any of these is
  `not_root` too, found by trying. How and why:
  `docs/hostagent-tiers.md` §3.7.

## Lifecycle

| Moment | What happens |
|---|---|
| Daemon boot | The daemon re-execs itself as `3pm __host-agent` (stdin null, stdout/stderr → `~/.3pm/hostagent.log`), waits for its readiness byte. |
| Every 1 s / 10 s / 5 min | Each tier collects on a blocking thread, stamps its part, and the cache re-assembles the snapshot (`collected_at_epoch_ms` = the fast stamp). A collector panic is logged, that tier's previous part stays served and the collector is rebuilt. |
| Agent dies (crash, `kill -9`) | The daemon respawns it after an exponential backoff: 1 s, 2 s, 4 s … capped at 30 s, reset once an incarnation has lived 60 s. `restarts` increments. |
| Agent alive but not answering (stopped, deadlocked, no longer listening) | Health check: from one period after readiness, the daemon pings `host.sock` every 5 s (`THREEPM_HOSTAGENT_HEALTH_MS`), each ping bounded by 3 s (never above the period). A success resets the count; 6 misses in a row (~30 s) and the daemon logs `host agent is unhealthy; replacing it` with the reason `wedged`, SIGTERMs it (SIGKILL after 2 s), and respawns it through the same backoff. `restarts` increments. A short `SIGSTOP` or a loaded box stays far below the threshold. |
| Agent answers with another protocol | It is alive, so it is left alone: one warning in `3pm.log` (`` host agent runs a different protocol — run `3pm update` ``), `hostmetrics` says `unavailable`, `restarts` does not move. A respawn would run the same binary and be skewed again. On Linux a respawn runs the daemon's own image (below), so this only happens on macOS after a new binary was installed and before `3pm update`. |
| Binary replaced on disk (`install.sh` `mv`s the new one over the path) | A respawn execs the binary's path while it is usable (the process is then named `3pm`). On Linux, once `current_exe()` reads `<path> (deleted)` (which nothing can exec), it execs `/proc/self/exe` instead, the daemon's running image even unlinked: daemon and agent stay the same version until `3pm update` replaces both; that incarnation's process name (`comm`, what `ps`/`top`/`pgrep` show) is `exe`. The handoff guardian's fallback (re-run the previous binary when the new one cannot be exec'd) follows the same rule. macOS: the path, as before. |
| `3pm kill` / SIGTERM / `3pm update` | The daemon sends the agent SIGTERM, waits 2 s, SIGKILLs, and only then removes its own sockets. `3pm update` therefore restarts both binaries together (the daemon checks the agent's protocol version on every request). |
| Daemon SIGKILLed | The agent notices its parent pid changed (1 s poll) and exits on its own, unlinking `host.sock`. |
| `THREEPM_HOME` deleted | Same watchdog: the agent exits, like the daemon does. |

The agent holds `flock(hostagent.lock)` for its lifetime (a second copy
started during a teardown race exits 0 and the respawn loop tries
again) and writes `hostagent.pid`.

## Memory

The agent runs **two** tokio workers (`WORKER_THREADS`; every collector
is on `spawn_blocking`, whose pool is sized apart): the default of one
per core gave 37 threads on a 32-core host, each worker keeping its own
glibc arena at its high-water mark.

On Linux with glibc, the agent's first act is `mallopt(M_MMAP_THRESHOLD,
128 KiB)`. Left dynamic, glibc raises the mmap threshold (and the trim
threshold with it) to the size of every multi-megabyte block freed — the
eBPF loader's parse of the kernel BTF frees 9 MB ones, a process table
filled afresh is megabytes more — and from then on such blocks come from
the arenas, each blocking thread's arena keeping its peak (~10 MB
apiece). Measured on a 32-core host: 80 MB of RSS left dynamic, 18.6 MB
pinned; with the eBPF gate and the single BTF parse, anonymous memory
after 15 min went 17.2 → 6.6 MB (Extended Monitoring on, user agent) and
17.1 → 8.3 MB (root agent). The call overrides `MALLOC_MMAP_THRESHOLD_`
and `GLIBC_TUNABLES=glibc.malloc.mmap_threshold=…` for the agent (glibc
reads those at exec, the `mallopt` comes after); the daemon and the apps
keep whatever their environment says. musl and macOS: nothing is set.

## Failure semantics — no silent fallback

The daemon **never collects inline**. If the agent cannot answer, the
command fails with a retryable error naming the log:

```console
$ 3pm hostmetrics
[3PM][ERROR] host metrics agent is not reachable (…) — see /home/u/.3pm/hostagent.log
$ echo $?
1
$ 3pm hostmetrics --json
{"error":{"code":"unavailable","message":"…"}}
```

Bounds on the daemon side, so the CLI can never hang on a broken agent:

| Situation | Behaviour |
|---|---|
| Agent mid-respawn | Connect retried every 100 ms for up to 2 s, then `unavailable`. |
| Agent just started, no sample yet | The agent waits up to 2 s for its first collection before answering `unavailable`. |
| Agent accepts but never answers (stopped, deadlocked) | Ping and request each bounded by 3 s → `unavailable`. |
| Agent binary from another 3pm version | Immediate `unavailable` (protocol skew); the agent is left running (a respawn would be skewed too); run `3pm update`. |

The first `3pm hostmetrics` right after a daemon spawn can therefore take
a few hundred milliseconds (it waits for the first sample rather than
failing); every later call is a cache read.

## Knobs and files

| Knob | Default | Meaning |
|---|---|---|
| `THREEPM_HOSTMETRICS_INTERVAL_MS` | `1000` (min `200`) | Fast tier period (cheap gauges). Read by the agent at start; restart the daemon to apply. |
| `THREEPM_HOSTMETRICS_MEDIUM_MS` | `10000` (min = fast) | Medium tier period (walks, forks, `statvfs`). A value below its floor is raised, with a warning in `hostagent.log`. |
| `THREEPM_HOSTMETRICS_SLOW_MS` | `300000` (min = medium) | Slow tier period (NTP probe, identity re-read). Same clamp rule. |
| `THREEPM_HOSTMETRICS_EXTENDED_MS` | `10000` (min = fast) | Extended Monitoring tier period (the kernel's own counters). Same clamp rule. |
| `THREEPM_HOSTMETRICS_EXTENDED` | unset | `1`/`true`/`yes`/`on` or `0`/`false`/`no`/`off` (any case): Extended Monitoring on or off, above `[hostmetrics] extended` (below); any other value is logged and ignored. Read from the daemon's environment at start: a handoff keeps the old environment, so `THREEPM_HOSTMETRICS_EXTENDED=0 3pm update` changes nothing — restart the daemon (`3pm kill`, then any command). |
| `THREEPM_HOSTMETRICS_TOP_MS` | `5000` (min = fast) | Top-processes tier period (`top_processes`). Same clamp rule. |
| `THREEPM_HOSTMETRICS_TOP_PROCESSES` | unset | Same values and rules as `THREEPM_HOSTMETRICS_EXTENDED`: the top-processes table on or off, above `[hostmetrics] top_processes`. |
| `THREEPM_NTP_SERVER` | `pool.ntp.org` | NTP probe server used by each collection; empty disables the probe. |
| `THREEPM_HOSTAGENT_HEALTH_MS` | `5000` (min `100`) | Daemon side: the agent's health-probe period. 6 missed probes in a row put the agent down; each probe is bounded by `min(3 s, period)`. Read by the daemon at start. |
| `THREEPM_SPAWN_TIMEOUT_MS` | `3000` | Readiness wait for spawned processes (daemon and agent alike). |
| `THREEPM_LOG` | `info` | Tracing filter, honoured by the agent too. |

| File under `~/.3pm` | Purpose |
|---|---|
| `host.sock` | Agent RPC socket (0600, same-uid peer check). Internal: the daemon is its only client. |
| `hostagent.lock` | The agent's singleton flock. |
| `hostagent.pid` | Its pid (informational; liveness is the socket ping). |
| `hostagent.log` | Its stdout/stderr — the first place to look when `hostmetrics` says `unavailable`. |

### `[hostmetrics]` in `config.toml`

```toml
[hostmetrics]
extended = true        # the default; the whole section may be absent
top_processes = true   # the default
```

Extended Monitoring: resolved as `THREEPM_HOSTMETRICS_EXTENDED` > this key >
`true`, once per daemon image (`hostagent::daemon_extended`); the daemon's
metrics sampler (per-process fields) uses that answer and hands it to every
host agent incarnation (host families) in `THREEPM_HOSTMETRICS_EXTENDED`,
so the two always agree. A change to this key takes `3pm update` (the new
image re-reads the file); an agent crash-restart alone does not. A value
that is not a boolean, or an unknown key, is named in `3pm.log` at daemon
boot and leaves the default; never fatal. `extended = false`: no extra
kernel file is read and nothing extra goes on the wire.

`top_processes` is resolved the same way and as independently
(`THREEPM_HOSTMETRICS_TOP_PROCESSES` > this key > `true`, once per daemon
image, `hostagent::daemon_top`). `top_processes = false`: no tier, no
`ps`/`nettop`/`ss` fork, no process walk, and neither `top_processes` nor
`sampled_at_epoch_ms.top` on the wire. What the table holds and how it is
read: `hostagent-tiers.md` §3.8.

## Rates

The OS only exposes cumulative counters. The agent keeps its previous
fast part and derives, over the window between two fast samples
(`rate_window_ms`, ≈ the fast period): `HostNic.rx_bytes_per_s` / `tx_bytes_per_s` /
`errors_per_s` and `HostDisk.read_bytes_per_s` / `write_bytes_per_s`.
A rate is absent on the first sample, for a NIC/mount that was not in
the previous sample, and after a counter reset (hot-plug, driver
reload) — never faked as a huge negative-wrapped number.

## Wire

Additive within protocol v1 (see `protocol.md`): `HostMetrics.collected_at_epoch_ms`
(optional; absent from a daemon predating the agent), the `*_per_s`
rates and `rate_window_ms` above, `HostMetrics.sampled_at_epoch_ms`
(optional `{fast, medium, slow}` stamps; absent before tiered sampling),
`DaemonInternals.host_agent_pid` / `host_agent_restarts`,
`HostMetrics.3pmDaemon` (optional; absent from a daemon predating
self-metrics), and `ErrCode::unavailable`. `host.sock` speaks the same `Request`/`Response`
NDJSON as `rpc.sock` but only serves `ping` and `host_metrics`; anything
else is `err{unknown_request}`.

## Troubleshooting

- **`unavailable` on every call** → `tail ~/.3pm/hostagent.log`; check
  `3pm internals` for a climbing `restarts` count (a crash loop) and
  whether `host.sock` exists.
- **`host agent is unhealthy; replacing it` in `3pm.log`** → the
  agent stopped answering its ping for ~30 s (reason `wedged`). The
  previous incarnation's last lines are in `hostagent.log`.
- **`host agent runs a different protocol` in `3pm.log`** → the agent
  binary is not the daemon's version (macOS, a new binary installed
  under a running daemon): run `3pm update`.
- **Stale `sampled` age (minutes)** → the agent is alive but its
  collector is stuck (typically a hung `nvidia-smi`/`ioreg`, though each
  subprocess has a 2 s deadline). `kill <agent pid>`: the daemon respawns
  it, the cache refreshes on the next tick.
- **Want fresher data** → lower `THREEPM_HOSTMETRICS_INTERVAL_MS`
  (cheap gauges) or `THREEPM_HOSTMETRICS_MEDIUM_MS` (process table,
  GPUs, sensors, mount space) and restart the daemon (`3pm update`).
  The fast tier is a few ms of reads; the medium one forks and walks,
  so keep it ≥ a few seconds on busy boxes.
