# Changelog

All notable changes to this project will be documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Added
- **musl builds for Alpine**: every release also ships
  `3pm-linux-x86_64-musl` and `3pm-linux-arm64-musl` (static, modules
  included). `install.sh` picks them on a musl host (`THREEPM_LIBC=glibc|musl`
  forces the choice), and the autoupdate module of a musl build updates to
  the musl archive. On musl the logged-in-users host metric is always 0 (no
  utmp) and the glibc malloc tuning of the agent does not apply.
- **Real-time limits per app: `ulimit_rtprio`, `ulimit_memlock`**
  (`--ulimit-rtprio`, `--ulimit-memlock`, SDK `start()`): the app's
  `RLIMIT_RTPRIO` (0–99, Linux only) and `RLIMIT_MEMLOCK` (a size or
  `"unlimited"`), set soft = hard right before `exec`, so a real-time
  audio app's threads may run `SCHED_FIFO` — an app of a systemd-started
  daemon inherited 0 and 8 MB. A root daemon grants any value (set before
  the drop to `user`; `no_new_privs` stays), a non-root one up to its own
  hard limit, and an app of a hosted user what `[users]
  max_ulimit_rtprio` / `max_ulimit_memlock` grant (nothing when unset) —
  refused at `start` otherwise. `3pm show` lists them.
- **Scheduling per app: `ulimit_nice`, `ulimit_rttime`, `nice`, `ioprio`,
  `oom_score_adj`, `cpu_affinity`** (flags, ecosystem, SDK `start()`),
  same model as `ulimit_rtprio`: set by the child on itself before
  `exec`, before the drop to `user`, judged at `start`. `RLIMIT_NICE`
  (0–40) gives back the negative nice a desktop session allows (Chromium's
  audio threads at -8, the plugin host's -11 fallback);
  `RLIMIT_RTTIME` (µs) SIGKILLs a `SCHED_FIFO` thread that never blocks; the
  nice value, the I/O class (`idle`, `best-effort[:N]`, `realtime[:N]`),
  the OOM killer bias and the CPU list are the app's from its first
  instruction. What only gives up is never refused; a daemon without
  `CAP_SYS_NICE` / `CAP_SYS_RESOURCE` refuses what the kernel would, a CPU
  the daemon does not have is refused, and a hosted user's app gets a
  nice / OOM score below the daemon's only from `[users] max_ulimit_nice` /
  `min_oom_score_adj`, never the realtime I/O class. A refused step
  names its field. `3pm show` lists them.
- **Link traffic by category, the agent** (lot 2 of
  `rd/logs-bytes-counter/PLAN.md`): `3pm agent` counts the bytes and
  messages it sends and receives per kind (`host`, `extended`, `top`,
  `procs`, `app`, `trees`, `events`, `shell`, `calls`, `control`,
  `traffic`, `logs`, `artifacts`, `blobs`, `api`), ships them as the
  `traffic` section every 5 s, and shows them in `3pm agent status` (a
  `traffic` row, "as of", `--json` table) from `<state>/traffic.json`,
  written at most once a minute. State-frame kinds are a pro-rata
  estimate of an exact total. The `logs` status gains `bytes_sent`.
- **Link traffic by category, the wire** (`threepm-link`, lot 1 of
  `rd/logs-bytes-counter/PLAN.md`): the closed list of kinds and the two
  directions both sides share (`threepm_link::traffic`: `KINDS`, `UP`/`DOWN`,
  `section_kind`), the split of a `state` frame's compressed size over its
  patches (`patch_weights`, Extended Monitoring — `host.extended`, tree
  members' `x` — and the top processes carved out; `pro_rata`, integer shares summing to the frame's size), and the
  `traffic` section (`node_state::TRAFFIC`, a name outside `Section`;
  `{since_ms, up, down: {kind: {b, m}}}`, read leniently by `traffic_view`, at most 64 unknown kinds a direction;
  `Traffic::known()` yields the closed vocabulary only).
  Additive: no `LINK_VERSION` bump; the agent sends it (the entry above).
- **`[module] kill_timeout_ms`** in `module.toml`: the least stop grace of
  a module's own rows, the dynamic ones too. A module refresh (`3pm
  update`, `3pm install`) raises an existing row's `kill_timeout_ms` to it,
  never lowers it. The agent module declares 10 s, room for its logs' last
  batch at a stop (rows linked before get it at the next update, no
  relink).
- **The wire of log shipping** (`threepm-link::logs`, first lot of
  `rd/logs-shipping/PLAN.md`): the records a node will post to
  `POST /api/buckets/{cluster}/logs` — `batch`, `src`, `tpl`, `cnt`,
  `line`, `dropped`, tagged `k`, every word a build does not know decoding
  to `unknown` — their bounds (`LogRecord::check`, `cap_text`) and the
  `200` answer; `welcome.logs {apps, host, files}` (absent = ship nothing)
  and `link.toml` `[logs] {apps, host, files, host_level, exclude}`, the
  host's half, combined by `LogsSettings::effective`. Logs still never ride
  the link itself (one DEFLATE context for every frame); the protocol doc
  gains §6b. Additive: no `LINK_VERSION` bump. Nothing ships yet.
- **The host's log files, found by their writer** (`log_sources`, lot B
  of `rd/logs-shipping/PLAN.md`). On Linux the host agent walks every fd
  table it can see once a minute (first pass at start, its own task,
  `THREEPM_HOSTMETRICS_LOGFILES_MS` to change the period) for regular
  files open for writing with `O_APPEND`, names each writer (pid, comm,
  systemd unit and container id from its cgroup), keeps one entry per
  `(dev, ino)`, and decides a verdict: `private` (shell and REPL
  histories, `.lesshst`, `.viminfo`, anything under `.ssh/` or `.gnupg/`:
  never shipped), `duplicate_journal` (rsyslogd, syslog-ng, journald),
  `own` / `duplicate_bus` (this daemon's and host agent's logs and its
  apps' files; another 3pm daemon is an ordinary writer), `binary` (a NUL
  byte in the first 4 KiB), `ship` as `docker-json` (dockerd's
  `containers/<id>/<id>-json.log`, owned by the container) or `text`. A
  path is declared only when, in the agent's mount namespace, it is a
  regular file (never a symlink) with the descriptor's `(dev, ino)`; it
  is opened `O_NOFOLLOW | O_NONBLOCK` and checked by `fstat`, so a writer
  in a container or a swapped symlink cannot point a root agent at
  another file. `dpkg.log`, `apt/history.log` and
  `unattended-upgrades/*.log` are listed while they exist; a file seen
  once stays listed while its path is that file or its rotation by the
  same owner. 2 000 files at most (`ship` first, the cut logged); a pass
  stuck over 30 s is abandoned and the previous list served. The daemon proxies
  `Request::LogSources` to the agent like `host_metrics`; root only (a
  hosted caller is refused like `internals`). Other OSes answer an empty
  list. Nothing reads or ships the files yet.
- **A linked node ships its logs** (`3pm agent`, lots C and D of
  `rd/logs-shipping/PLAN.md`), by default, to a backend that says
  `welcome.logs`: the 3pm apps' output (a second `log` subscription to
  the bus, its own cursor), the system journal at `info` or worse (one
  `journalctl --follow --output=json` child, resumed after the cursor of
  the last accepted batch, first start from now, entries over 24 h old
  dropped) and the files the host agent lists with verdict `ship`
  (tailed from their end at first sight, rotation and `copytruncate`
  followed, 512 files, parked after 10 min idle; `EACCES` skipped until
  the next 60 s pass). Every line is redacted, folded, mined into
  templates (Drain, ids per source never reused, state in
  `<home>/agent/logs/miner`) and counted per 5-minute window; a raw line
  leaves only when it is news (`new`, `error` once per template per
  hour, `back`, `spike`, `return`, the 30 s of `context` before a new
  error), within 4 KiB/s per source (256 KiB burst) and a 4 MiB RAM
  ring while the backend is away. A batch every 10 s, `POST
  /api/buckets/{cluster}/logs`, gzip NDJSON, never on the WebSocket;
  file offsets and the journal cursor (`<home>/agent/logs/offsets`,
  `journal.cursor`) advance only on `200`, so a line not accepted
  before a stop is read again and stored once. `[logs]` in `link.toml`
  (`apps`, `host`, `files`, `host_level`, `exclude`) turns sources off,
  read at the agent's start. At a stop the agent closes its windows and
  tries a last batch within 5 s. Linux for the journal and files; on
  macOS only the apps' output.
- **`3pm agent status` has a `logs` row**: `24 sources (8 app, 15 file,
  1 journal), 451 lines read, 33 shipped, 33 templates; batches 16 ok,
  0 retried, 0 dropped`, then `skipped …`, `lines dropped …` by reason
  and the last error when there are any (`--json`: `logs`).
- **automonit's log files join the agent's.** When the root-installed
  `automonit` module runs, the agent asks its `logs` action every 5 min
  (`3pm-automonit/docs/logs.md`): the files a service names itself
  (nginx and Apache configuration, MySQL, PostgreSQL, MongoDB, Redis),
  with a format. Merged by `(dev, inode)` with the host agent's list,
  automonit's owner and format win. Only the module row is asked, never
  an app a hosted user named `automonit`; a file automonit adds alone
  must look like a log (`/var/log`, a `log` directory, `*.log`), sit
  outside `/etc`, `/root` and the private places, have one link and
  the owner uid it declared.
- **Compression inside the end-to-end channel for bulk sessions.**
  `3pm fleet cp` compresses by default (`--no-compress` to opt out);
  `3pm fleet ssh -C -- <cmd>` asks for it when the command runs
  without a pty; `-v` says whether it is on. zstd level 1, one frame per
  sealed record, raw when it would not shrink (one byte of overhead),
  never past 65 518 bytes once opened. Never a shell, a pty, `attach`
  or a forward (keystroke latency; CRIME-style length leaks). Agreed
  in the Noise handshake's payloads, which the handshake authenticates:
  no new request field, nothing for the backend to strip; an older agent
  or 3pm simply never compresses.
- **`lan`, a virtual Ethernet between the root Linux nodes of a bucket**
  (bundled module, marked `auto`: every 3pm installs it; off with
  `[modules] off = ["lan"]` in `config.toml` or `3pm uninstall lan`).
  Where it cannot work (not Linux, not root, no `/dev/net/tun`, not
  linked) it stays up and idle, says why once, and `lan.status` gives
  the reason: never a crash loop; it then declares no `link` action and
  its peers, which keep only nodes declaring it (`fleet:read`), never
  call it; failed opens back off per peer 5 s → 10 min with jitter, and
  past 6 a minute all dialling pauses. It makes a TAP
  `3pmlan0` on `10.77.0.0/16` (address and MAC derived from the node id)
  only once a peer shows up, removes it — and its sessions — 10 min
  after the last one leaves, and never puts it over a range already in
  use on the host (a default route split in `/1` halves, OpenVPN's
  `def1`, is no range in use; a TAP held by the previous `lan` is
  retried, not idle). It finds its peers itself (`3pm fleet nodes|apps
  --json`, needs `fleet:list`; sessions need `fleet:invoke`): at start,
  5 s later, every 15 s while alone, then every minute, and at once for
  a session from an unknown node (≤ 3 s wait). Frames ride fleet stream
  sessions, agent to agent between LAN neighbours (the node's `direct`
  setting, `all` by default; `files` keeps live legs on the relay),
  through the relay otherwise: one session per pair (the lower
  node id dials; 14 peers dialled at most, the nearest); a hello with an
  address check; backoff 5 s → 10 min; renewal after 23 h, switching at
  the new session's first frame on both sides (the serving side keeps a
  working session until the renewal's first frame: no `lan.peer_down`
  then `lan.peer_up` at each renewal); a 25 s keepalive (a ping); a session
  given up is ended and drained ≤ 2 s before it is closed; a learning
  switch (floods share one buffer); `broadcast_route`; a per-peer token
  bucket (`rate_kbit`, 4 Mbit/s, `0` = none) cut by a latency guard (a
  ping every second while busy; over a 1 s round trip the rate is halved
  down to 64 kbit/s, under 400 ms it grows back a tenth per ping); a
  warning at `peer_up` unless `expect_direct = true`; `allow` (alias
  `peers`) as an allow-list; `netns = "3pmlan"`: a namespace holding
  only the lan (a macvlan `3pmlan1`, its own address and MAC, no default
  route) for Wine games that bind to the default route's interface; the
  MAC set after udev settles, then read back. Action `lan.status`;
  metrics `lan.frames`/`lan.bytes`/`lan.dropped`/`lan.peers`/`lan.rtt`;
  events `lan.peer_up` (with `call_id`)/`lan.peer_down`. Trust boundary:
  the bucket (a member can claim another's id until the daemon passes
  actions a verified caller).
- **`[modules] off = [...]` in `config.toml`** switches `auto` modules
  off: never auto-installed, and uninstalled at the next `3pm update` or
  spawned daemon (apps deleted, copy removed, nothing written to
  `modules.off`). The daemon checks the section with the rest of the
  file.
- **Signed releases.** `just publish` writes `v<X>/release.txt` (version,
  sha256 of every archive and of a new versioned `v<X>/install.sh`) and
  `v<X>/release.sig` (ed25519, signed on the publishing Mac with
  `~/.3pm-release/main.key`; `just release-keygen` makes the main and the
  offline rescue key). The `autoupdate` module — the `update` action and
  the hourly check alike — verifies the manifest against its two embedded
  public keys, the version (the one asked for, strictly newer), then the
  archive and `install.sh` hashes, and runs that `install.sh` with
  `THREEPM_ARCHIVE=<verified archive>`; anything else runs nothing.
  `install.sh` honours `THREEPM_ARCHIVE`, makes a missing `checksums.txt`
  fatal and no longer falls back to GitHub (nor to a hard-coded version).
  A fresh `curl | sh` stays trust-on-first-use.
- **`3pm fleet ssh`: only the caller's own key opens a session.** A node
  runs a session (shell, `-- cmd`, `cp`, `attach`, `-L`, `-W`, `-R`,
  `--user`) only for a caller key listed in its own `authorized_keys`
  (`<agent state dir>/authorized_keys`, sshd `StrictModes`, re-read per
  session; absent or empty = every session refused), and only for the
  request that caller placed: the handshake's prologue (label `… v2`)
  binds the canonical request — argv, `pty`, `--user`, attach target,
  `host:port`, the `-R` listen/connection, `bind:port`, `term`/size,
  `record`. Refusal: agent code `key_not_authorized` naming the
  fingerprint, exit 255 in every mode (`-L`/`-W`/`-R` stop at the first).
  New commands: `3pm link authorize|deauthorize`, `--authorize` on `3pm
  link` (and `THREEPM_LINK_AUTHORIZE`), keys in `3pm link status`;
  `3pm fleet key` (works without a link), `fleet key new --out`, `fleet
  key push|revoke <node…|--all>`; `THREEPM_CLI_KEY` for a rescue key.
  `cli.key` moves beside `known_nodes` (`$THREEPM_HOME/cli.key`) and
  survives `3pm link delete`. The key is checked before the session
  takes its permit (16 handshake slots, 8 per caller so a stolen token
  cannot hold them all, 5 s for the first message).
  `--record` asks for recording (off by default, see Changed), the
  command line then says "this session is recorded", and `[shell]
  record = "never"` in `link.toml` wins on the node. Tablets: `TABLOID_AUTHORIZE` / an unconditional
  `authorize` provisioning step.
- **`autoupdate` only moves forward**: `update` resolves the version in
  Rust (`{}` = the latest) and refuses one that is not strictly newer;
  going back is `sh install.sh <version>` by hand.
- **Rust SDK (`threepm-io` 0.50.0): stream sessions.** `io().stream(name,
  Action::new().idle_ms(ms), |session, params| …)` declares a stream
  action (`stream: true`, the idle period as its `timeout_ms`, ≤ 24 h;
  the concurrency slot held up to 24 h); the handler runs on its own
  thread, and its return closes the session and answers its data — or
  the session's stats for `Value::Null`, or the leg's failure.
  `io().open(node, app, action, params, Open::new())` places the `call`
  (`stream: true`, `input: {live: true}`, instance 0) and returns a
  `Session` once the ticket is in (≤ 5 s, else `daemon_too_old`); `None`
  and `"self"` are this node, by its `link.toml` id (`io().node()`).
  `Session`: `send` / `send_frame` (never block), blocking `recv`
  (`Ok(None)` at the other side's `END`), `end` (half-close, ≤ 3 s for
  the agent's answer), `close` (also unblocks a `recv` in another
  thread), `stats` (`sent`, `dropped`, `bytes_out`, `received`,
  `bytes_in`, `missing`, `discont`), `failure`, `result(wait)`. The legs
  are the Node SDK's: a chunked `PUT` and a `GET ?live=1` on the agent's
  `data.sock` (`$THREEPM_DATA_SOCK`, `agent/data-<uid>.sock`,
  `agent/data.sock`), one HTTP chunk per frame written as soon as it is
  sent, the 100 ms / 8 MiB abandon sink (oldest non-`KEY`, then
  non-`INIT` first), an early `401`/`422`/`503` failing the leg at once,
  a refusal after the ticket failing the session, and a broken leg
  settling the call `lost` 10 s later. `CallError {code, message,
  call_id}` carries the wire codes; `threepm_io::live` is the framing,
  gated on `live_frames.json`. At most 16 sessions per `Io` and 16 calls
  in flight; a closed session's call stops counting against them. Unlike
  the Node SDK, a control reconnect does not fail an open session: a call
  that has its ticket gets the reply grace and is `lost` only if no reply
  came by then (one still waiting for its ticket fails at once). The
  writer sends one chunk per frame as soon as it is queued, what is
  already queued in one vectored write; `send_frame` moves its payload
  and `send_shared(&Arc<[u8]>)` shares one across sessions. Every leg
  must carry a frame at least once a minute (the reading leg's 60 s
  guard). Still not there: `io().call` and blob inputs/outputs.
  Example: `packages/rust/examples/stream_echo.rs`, proven against a real
  daemon, agent and backend with a Node caller
  (`3pm-backend/tests/it/real_agent_streams_rust.rs`).
- **`3pm fleet ssh <node> -R [bind:]port:host:hostport`: a port of the
  node forwarded back here, as ssh's.** Repeatable, with a shell or
  `-N`; refused with `-W` or a command. The node listens on its
  loopback (port 0: it picks one, `Allocated port N on <node>` on
  stderr) and hands each connection back; this side connects to
  `host:hostport` and carries it over a sealed `nodeForward`
  (`accepted`). The command line places every call, so the end-to-end
  channel, TOFU and the `3pm:forward` rule are `-L`'s. A node binds
  anything but its loopback only when its own `link.toml` says `[shell]
  gateway_ports = true` (never the backend's to change); a re-link
  keeps the setting. A connection not taken over in 10 s is closed; a
  local connect that fails is one stderr line and closes the node's
  connection at once. Ctrl-C or the shell's end closes the port.
- **Link (additive): the wire of `-R`.** A `listen {bind, port, input,
  output, from, from_hostname?, peer?, e2e}` command under
  `node:forward`, whose sealed legs carry `threepm_link::listen`
  messages (`keepalive`, `bound {port}`, `accepted {conn, peer}`; an
  unknown kind is ignored), and `accepted: {listen, conn}` on `forward`
  in place of a destination. An older agent answers `unknown command`.
  `link.toml` gains an optional `[shell]` table (`gateway_ports`,
  absent = off). The Node SDK's generated client gains `nodeListen`.
- **`3pm fleet ssh <node> -W host:port`: ssh's `ProxyCommand`.** stdin
  and stdout through the node to `host:port`, over `-L`'s sealed
  `nodeForward` (no node or backend change): `ssh`, `scp`, `rsync`,
  `git` and VS Code reach a node's sshd with no route to it — `Host
  *.3pm` / `ProxyCommand 3pm fleet ssh $(echo %h | sed 's/\.3pm$//') -W
  localhost:%p` in `~/.ssh/config`. stdout carries data only; exit 0 on
  a clean end, 255 on a refusal or a cut. Refused with a command, `-L`,
  `-N`, `-t`, `-T`.
- **`3pm fleet ssh` is end-to-end encrypted, every mode.** The shell,
  `-- <cmd>`, `fleet cp`, `fleet attach` and `-L` are sealed between the
  command line and the target's agent (`Noise_XX_25519_ChaChaPoly_BLAKE2s`,
  prologue = call id + node id; records `[u16 length][Noise message]`
  on the existing lossless legs, relay or LAN direct): the backend
  relays bytes it cannot read nor change — a record that does not open
  ends the session (`e2e_broken`, exit 255). Keys: `shell.key` in the
  agent's state directory (fingerprint in `3pm link status`, "shell
  key"), `cli.key` in the caller's `~/.3pm/agent/` (its fingerprint,
  `cli_key`, on the audit `open` line — metadata, not a credential).
- **Trust on first use: `~/.3pm/known_nodes`, `--accept-new`, `3pm fleet
  known-nodes ls|rm`.** The first session to a node asks on `/dev/tty`
  (refused with no terminal unless `--accept-new`); a key that changed
  is refused, ssh's `NODE IDENTIFICATION HAS CHANGED`, exit 255. Never
  filled from the backend.
- **The node records an end-to-end session itself.** Its agent writes
  the asciicast from the screen leg **before** sealing (the writer moved
  to `threepm_protocol::cast`, shared with the backend: same rules, 64
  MiB cap, never waits), uploads it as the call's artifact
  (`putCallArtifact`) and names it in `exited {recording}`; `record:
  false` in the request (the bucket's `record_shells` off) records
  nothing. A sealed session may go LAN direct and be recorded. An agent
  killed mid-session loses that recording (the backend's `ended` says
  `recording: lost`); its temporary file is swept at the next start.
  A recording that stopped early (cap, gap) says why on its upload
  (`x-3pm-stopped: cap|gap|gap_end`), so its `summary` matches one the
  backend made. A copy (`3pm fleet cp`, up or down) is never
  recorded, by the agent nor the backend (`threepm_link::cp`: its four
  fixed scripts, matched byte for byte, `is_cp_transfer`): a
  download's screen is the file. The audit keeps its command line and
  paths; no `ended` line says `lost` for it. `-L` is never recorded.
- **Link (additive):** `e2e` and `record` on `shell`, `exec`, `attach`,
  `forward` and `run_as`; `shell_e2e` announced in `hello`; `cli_key`
  on `ShellEvent::Open`; `recording` on `exited`; agent error codes
  `e2e_required` and `e2e_broken`.
- **`--no-e2e`**, for a node whose agent cannot encrypt only (the
  backend refuses `e2e` to it at once, `ERR_NODE_NO_E2E`): the session
  goes in clear, as before, after a warning on stderr.
- **`3pm fleet ssh` between LAN neighbours goes agent to agent.** When
  both nodes are `direct: all` (and, for a session in clear, the
  bucket does not record shells),
  the command line hands the session's legs to its own agent
  (`POST /legs` on `data.sock`, its own socket only; a lossless `PUT
  ?pipe=1`), which dials the target for the screen while the target
  dials it for the keyboard; the relay stays the fallback after a 1 s
  hold, lossless. Wire: a lenient `peer` on `Shell`, `Exec`, `Attach`
  and `Forward` (inside `RunAs` too), additive. Shell, command, attach
  and `cp`; `-L` forwards stay on the relay.
- **`3pm fleet ssh -L [bind:]port:host:hostport <node>` (repeatable),
  and `-N` for the forwards alone: a local port forwarded through a
  node, like ssh's.** One listener per `-L` (127.0.0.1 unless a bind
  address is given), each accepted connection its own call; the node
  connects to any destination it can reach. A failed connection is one
  line on stderr, never the end of the others; with `-N` the command
  runs until `SIGINT`/`SIGTERM` (exit 0). Needs `3pm:forward` in the
  target's `callers` and `node:forward` in its `caps`. A listener off
  the loopback (`*`) is warned about; at most 16 connections are
  carried at once (the target's sessions, shared with shells), the next
  ones waiting.
- **Link: the wire of `3pm fleet ssh -L`.** A new capability
  `node:forward`, in the default set, and a `forward {host, port, input,
  output, from, from_hostname?}` command: two `pipe` legs of raw bytes
  in `live` messages, `END` a half-close, answered by `exited {code}`
  or the `connect …` error. Additive: an older agent answers `unknown
  command`. `link.toml` goes to version 4 with the rule of version 3: a
  version-3 file listing exactly the five capabilities of that day
  reads as all of them (so **a node linked with the defaults gains
  `node:forward` on update without a re-link**); any other list stays
  as written. Take `node:forward` out of `caps` to refuse forwards.
- **`3pm fleet ssh -l <user>` (or `<user>@<node>`), also on `-- <cmd>`
  and `3pm fleet cp -l <user>`: the session as another unix account of
  the node.** An agent running as root becomes it (its groups, home,
  shell; `setgroups` → `setgid` → `setuid`, uid 0 checked out of reach,
  `PR_SET_NO_NEW_PRIVS` on Linux, the pty chowned to it — the daemon's
  hosted-user drop, copied); one that is not root takes only its own
  name and refuses any other; an unknown account is refused. Link: a
  new op `run_as {user, req}` wrapping a `shell` or an `exec`, so an
  older agent answers `unknown command` and never runs the session as
  itself. The name is checked everywhere (`valid_user_name`,
  `[a-z_][a-z0-9_-]{0,31}`). A refusal the agent sends before the legs
  open is now read back and printed, instead of "the screen leg was
  refused".
- **`3pm fleet attach <node> <app>`: the terminal of a `tty = true` app
  on another node**, like `3pm attach` (`--instance`, `--raw`, `--peek`,
  `--read-only`; `~.` after Enter detaches and the app runs on; exit 0
  on a detach, 255 otherwise). A new link op `attach` (capability
  `node:shell`): the target's agent relays the daemon's own `attach`
  over a fresh `rpc.sock` connection, message for message; audit
  `open`, `attach` (new event kind), the raw input lines and `close`.
- **Link: the wire of `3pm fleet ssh`.** A new capability
  `node:shell`, in the default set; a `shell {term, cols, rows, input,
  output, from, from_hostname?}` command (the two blobs are the
  keyboard and screen legs) answered by `exited {code?, signal?}`; and
  a `shell {call, from, ts, event}` audit event whose `event` is
  `open`, `line`, `screen` or `close`. Additive: an older agent answers
  `unknown command`, an older backend never sends it. `link.toml` goes
  to version 3, and **a node linked with the default capabilities gains
  `node:shell` on update without a re-link**: a version-2 file listing
  exactly the four earlier capabilities reads as all of them, any other
  v2 list stays as written. Where the agent runs as root that is a root
  shell, opened by whoever the target's `callers` rules admit: take
  `node:shell` out of `caps` to refuse it. The agent serves it, and
  `3pm fleet ssh` opens one (below).
- **`3pm fleet ssh <node>`: an interactive login shell on another node
  of the bucket, like ssh.** The node is a `3pmn_…` id, a hostname or
  an unambiguous start of one; the session is placed with this node's
  own identity, like `3pm fleet call` (the `invoke` scope on the
  target, and its `callers` rules must name `3pm:shell` when it has
  any), and both directions are lossless legs through the backend. The
  terminal is raw for the session and given back on every way out
  (return, error, a panic on any thread, a `SIGTERM`, `SIGHUP`,
  `SIGINT` or `SIGQUIT` sent to the command, which then exits 255); its size follows the window
  (`SIGWINCH`), `TERM` is passed through. `~.` right after Enter
  disconnects, `~~` types one `~`. The exit code is the shell's (`128 +
  signal` when a signal ended it), and 255 with one line on stderr when
  anything else did: `~.`, a leg cut, the node refusing. Refusals say
  which rule is missing — the `invoke` grant, `3pm:shell` in the
  target's `callers` (by the backend's `ERR_SHELL_NOT_ALLOWED`, not its
  wording), `node:shell` off in the target's `link.toml`, an agent or a
  backend too old, the node offline, its shells all in use. The keyboard
  pings every 20 s so an idle session is not swept, and is queued
  rather than awaited, so a paste into a shell that does not read never
  stops the screen. Needs a local daemon and a linked host, like every
  `3pm fleet` command.
- **`3pm fleet ssh <node> -- <cmd>`: one command instead of a shell.**
  Through the target's login shell (`sh -c`; several words each
  quoted, one word taken as the whole line). On a pty when stdin and
  stdout are terminals or with `-t`, never with `-T`; otherwise on
  pipes: stdin streamed then closed, stdout to stdout and stderr to
  stderr, binary-safe, no raw mode and no `~.`. Exits with the
  command's code (`128 + signal`; 141, silently, when stdout stopped
  being read, `… | head -1`; 255 when the session ended any other
  way). `echo x | 3pm fleet ssh n -- wc -c` prints 2.
- **`3pm fleet cp [-r] [-p] SRC… DST`: files to and from a node, like
  scp.** One side is `<node>:<path>` (a node as `fleet ssh` takes it;
  `<node>:` is its home, `~/x` works; a local name with a `:` is
  written `./a:b`); several sources need a directory. Built on the
  command sessions of `fleet ssh -- <cmd>`, one per source, so the same
  grant and `callers` rules apply and the audit shows each command (`sh
  -c '…' 3pm-cp <paths>`; a node path starting with `-` is never an
  option). A file goes up through an owner-only `<dst>.3pm-tmp-<rand>`
  renamed over its destination, and comes down into a temporary file
  here renamed once the node's command exited 0: a cut never leaves half
  a file under its name. `-r` streams a tar archive both ways (built and
  unpacked here by the `tar` crate, never the local `tar` binary; the
  node's own `tar` there); `-p` keeps modes and times (a file down with
  `-p` travels as a one-member archive). A file is never written over a
  directory; symlinks inside a tree stay links. Exits 0 when every source
  was copied, else 1 with one line per failure; the node's stderr is
  shown as it comes. A progress line (bytes, rate) on stderr when it is
  a terminal. 100 MB up and back on loopback (Linux, debug builds):
  about 0.2 s up, 0.6 s down.
- **Link: `exec`.** A new command `exec {argv, pty?, term, cols, rows,
  input, output, from, from_hostname?}` under `node:shell`, answered by
  `exited`, and a `shell` audit event `exec {command, pty}`; two new
  `attach` kinds, `eof` (`0x05`, caller → target: close the command's
  stdin) and `stderr` (`0x1d`, target → caller). All additive: an older
  agent answers `exec` with `unknown command`, an older reader ignores
  the new kinds and the new event.
- **Agent: the target side of `3pm fleet ssh`.** A `shell` call opens
  the login shell of the agent's own unix user (read in `passwd`, never
  `$SHELL`; `/bin/sh` when it names none) on a fresh pty, as a login
  shell (`-bash`), in its home, with an environment built from nothing
  (`TERM` from the caller, `HOME`, `USER`, `LOGNAME`, `SHELL`, a
  default `PATH`, the agent's `LANG`/`LC_*`): nothing of the agent's
  (`THREEPM_HOME`, the module variables) reaches it, and the shell
  holds no descriptor but its terminal. Its keyboard and screen go
  through the backend on the two blobs of the call, opened with the
  node's bearer of the moment. **Either leg ending, or the shell
  exiting, ends the session**: `SIGHUP` to the shell's process group,
  `SIGKILL` 5 s later if it is still there; the call answers `exited
  {code?, signal?}`. A keyboard leg silent for 90 s (the caller pings
  every 20 s) counts as ended: a backend gone with its host does not
  hold a shell for the call's day. **A shell survives a reconnect of
  the link**: it runs beside the connection, not in it, and answers on
  whichever connection is live when it ends. 16 shells at once per
  agent (their own count, across reconnects; refused `unavailable` past
  that), refused `forbidden` without `node:shell`. **Audit**: every command line, as the screen
  showed it (after `Tab`, after a history recall, prompt included),
  goes to the backend as a `shell` event and to the agent's log; a
  line typed while the terminal does not echo (a password) is
  `[hidden]`; under a full-screen program (vim, htop) the raw keys are
  logged instead, with an event on entering and leaving it — **so a
  secret typed into a full-screen form (`nmtui`, `pinentry-curses`) is
  logged as typed**; 4 KiB a line at most; the audit never takes more
  than half the link's control queue (a flood drops lines, never
  another call's frames). Not complete, and not claimed to be: `tmux`, an escape
  sequence or typing blind escape it — the backend records the opening
  and the end itself. **An agent that stops (`3pm restart agent` or
  `3pm update`, typed in that very shell included) hangs its shells up
  before it closes the link** and waits, at most 1.2 s, for each to
  deliver its end: the caller exits 129 (`SIGHUP`) and the call settles
  `exited`, rather than 255 and a call left open; the restart itself
  goes through. A shell that ignores the hang-up is killed 0.5 s later
  (not 5 s) by the agent still running: a shell is a session of its
  own, which the daemon's kill of the agent would not reach. Works on a macOS target as on Linux.
- **Hosted users read the host metrics.** `host_metrics` (`3pm
  hostmetrics`, the SDK's `hostMetrics()`) no longer answers
  `forbidden` to a hosted account — a kiosk running as `srt` can show
  the machine's CPU, memory, network and temperatures. Its reply drops
  the fields that name other accounts: `top_processes` (root's
  processes included) and `sessions`.
- **Per-core CPU frequency: `HostMetrics.per_core_freq_mhz`.** One
  MHz figure per logical CPU, in `per_core_percent`'s order, refreshed
  every fast tick (1 s); until now only cpu 0 was live
  (`cpu_frequency_mhz`, unchanged). Additive on the wire and the link.
- **Network traffic per app: `net ↓/↑` in `3pm ls`.** Received and
  sent per second over the last 5 s for every app (its whole process
  tree, each socket once, its traffic with the outside: a connection
  between two of its own processes counts for each process, not for the
  app), per process under `3pm ls --tree` and in the ` Processes` table
  of `3pm show`, with the totals in `3pm show` and `ProcessRecord.net` / `TreeProc.net` in
  `--json` (`rx_bytes_per_s`, `tx_bytes_per_s`, `rx_bytes`, `tx_bytes`,
  `partial`). Measured by the daemon: on Linux a `sock_diag` dump of
  the TCP sockets with their byte counters, the members' fd tables, and
  the kernel's destroy notifications so connections that close between
  two windows still count (an inbound one by its listening port; an
  outbound one no window saw is not counted); on macOS one `nettop
  -d` per socket kept running on a pty (loopback counted, the pairs
  inside an app taken out). Unprivileged, and
  what it costs follows the apps, not the host: both kernel streams are
  filtered on the apps' ports (`inet_diag` bytecode, a BPF filter on the
  notification socket), fd tables are read on a 10–30 s rhythm, and an
  idle daemon holds no socket, thread or `nettop`. `3pm
  internals` gains `network meter` (`DaemonInternals.net_meter`). The
  link carries it (`RedactedRecord.net`, `RedactedProc.nt`, additive)
  and tree rows sum it (`CompactRow.net`). The `ls` column is drawn
  when some app has a figure, and dropped right after `ports` on a
  narrow terminal. `tree::budget_ext` is now `budget_extras` (it sheds
  `net` too). `metrics/sock_diag.rs` is the netlink transport the
  listening ports and the meter share.
- **`3pm install <name>` downloads a module that is not bundled.** A
  name that is neither a path, nor bundled, nor installed is looked up
  on the download site (`$THREEPM_CDN_URL`, the one `autoupdate` uses):
  `modules/<name>/latest/version.json`, the archive for this platform,
  its size bounded and its sha256 checked before it is opened, every
  entry checked on unpack (no absolute path, no `..`, nothing outside
  `<name>/`, no link out, the manifest naming `<name>`), then the same
  install as `3pm install ./dir`. `<name>@<version>` pins (always the
  site), `<name>@latest` unpins; the installed copy carries
  `.3pm-site.json`, and `3pm update` takes a newer release of every
  unpinned site module in place (a site out of reach is a warning).
  https only, a `file://` or loopback mirror aside; the archive URL is
  rebuilt from the site, never taken from the index. `3pm install
  automonit` replaces the `curl … | sh` line in `just publish`'s
  summary, `version.json`'s `install` and the site's home page; the
  script stays for a 3pm up to 0.42.0. The site helpers
  (`$THREEPM_CDN_URL`, version grammar, `curl`/`wget` fetch) moved from
  the `autoupdate` module to `threepm_protocol::site`, shared by both.
- **`3pm rename <app> <new-name>`: rename an app in place, without
  restarting it.** Same ids, pids and counters; every instance, the pid
  files and the log files named after the app (live and rotated, the
  descriptor kept open) follow the name, and so do the running pumps'
  events. Refused on several apps, a taken name, a module app, or a log
  file already at the new name. Wire, additive within protocol v2:
  `Request::Rename { target, name }` → `ok`. Node SDK:
  `client.rename(target, name)`.
- **Interactive apps: `tty = true` (`--tty`) and `3pm attach`.** An app
  can run on a pseudo-terminal the daemon holds (`isatty` true, its own
  session, 120×40, `TERM=xterm-256color`, `COLORTERM=truecolor`), and
  `3pm attach <app>` (alias `console`; `--instance`, `--read-only`,
  `--raw`, `--peek [--json]`, `--paste`) connects a terminal to it:
  the daemon keeps a screen per app (1 000 rows of history, answers to
  the app's terminal queries), sends each client the difference against
  a mirror of its terminal, forwards the history into the client's own
  scrollback, and follows the alternate screen; `Ctrl-Z` detaches and
  never reaches the app, and the client's terminal is restored on every
  way out (a signal included). Piped, stdin goes to the app and its
  bytes come out; `--peek` prints the screen. The log of a `tty` app is
  one file, stripped of escape sequences (`threepm-core::ansi`);
  `error_file` is refused with `tty`. The terminal and the screen cross
  `3pm update` (same pid; attached clients re-attach by themselves).
  Wire, additive within protocol v2: `Request::Attach`,
  `Response::Attached`, `ErrCode::NotTty`, `ProcessRecord.tty_viewers`,
  and the attach kinds on the live framing (`threepm_protocol::attach`,
  `docs/protocol.md` § *Attach*). A `tty` app dies with a daemon that
  crashes (`SIGHUP`); the other limits are listed in `FEATURES.md`. The
  Node SDK has no `attach` method, deliberately: a terminal session is
  not a command a script calls like `restart` (`StartOptions.tty` is
  there, from `just gen`).
- **`just vendor`**: fmt, clippy and the tests of the vendored crates
  (`vendor/vt100`), part of `just ci` and of CI's `modules` job.
- **Top processes in `3pm hostmetrics`, on by default.** Which
  processes of the whole host — not only the apps 3pm manages — take the
  CPU, the memory and the network: `HostMetrics.top_processes`, one
  table of at most 30 rows sorted by pid (the ten largest by CPU, by
  memory and by network rate, merged), each row `pid`, `name`,
  `user`, `cpu_percent` (CPU time over the window, 100 = one core),
  `mem_bytes` (RSS), `footprint_bytes` (macOS) and
  `net_rx_bytes_per_s` / `net_tx_bytes_per_s`.
  `3pm hostmetrics` draws it as three sections (cpu, memory, network),
  ten rows each.
  - **A tier of its own**, `top` (`THREEPM_HOSTMETRICS_TOP_MS`, 5 s,
    floor = fast), outside the first-snapshot gate;
    `HostSampledAt.top` dates the table. `[hostmetrics] top_processes`
    in `config.toml` (default `true`),
    `THREEPM_HOSTMETRICS_TOP_PROCESSES=0|1` above it, read once per
    daemon image; off, nothing is forked, walked or sent.
  - **Sources.** macOS: `ps` (the one unprivileged way to every user's
    processes) and `nettop -n`. Linux: a `sysinfo` walk without threads
    and `ss -tinpH` — TCP only, every socket under a root daemon, the
    daemon's own user's otherwise; a socket several processes hold
    counts whole for each. Each fork has 2 s. A rate the platform cannot
    give is absent, never zero.
  - **Names, never arguments or paths.** macOS publishes the kernel's
    16-byte name, completed from the executable's file name when it may
    be cut (never from the process's own title); Linux the kernel's
    process name.
  - **Memory: the footprint on macOS, the RSS on Linux.** The RSS
    misses a Mac's biggest consumers (a VM at 8.2 GB of footprint shows
    803 MB of RSS; WindowServer, at 4 GB, fell out of the top ten), so
    macOS ranks memory on `footprint_bytes` — what Activity Monitor
    calls Memory — read from one `top -l 0 -s <period> -stats pid,mem`
    the host agent keeps running: ~1.8 % of a core at 5 s (measured
    2026-10-01; a `top` per tick would cost 15 %). A process `top` did
    not list has no memory rank rather than its RSS; with `top` down,
    memory ranks on the RSS. `top` starts with the tier, is reaped with
    it, is killed when it stops writing, and is started again at most
    once per 30 s. Linux keeps the
    RSS. `3pm hostmetrics` heads the column `footprint` or `rss`.
  - A process has a row once it has been seen twice (no figure is
    invented for a first sample), so one that lives less than a period
    never shows; a failed read empties the table.
  - **Link:** the table rides the `host` section, keyed by pid — no
    protocol or link version change; a patch carries the rows that
    moved (about 200 bytes deflated for 24 rows, measured on synthetic
    data).
- **Extended Monitoring, on by default.** `[hostmetrics] extended` in
  `config.toml` (default `true`), `THREEPM_HOSTMETRICS_EXTENDED=0|1`
  above it, read once per daemon image and handed to the host agent so
  both agree; `false` reads no extra file and sends nothing extra.
  - **Host (Linux):** a new tier, `extended`
    (`THREEPM_HOSTMETRICS_EXTENDED_MS`, 10 s, floor = fast), adds
    `HostMetrics.extended`: the kernel's own counters by family —
    `cpu`, `softirqs`, `meminfo`, `vmstat`, `diskstats`, `netdev`,
    `snmp`, `netstat`, `snmp6`, `sockstat`, `pressure`, `ksm`, `hwmon`,
    … (27 declared on this tier, those a node lacks cost one failed
    `open`), plus `cpuidle`, `cpufreq`, `thermal` on the slow tier. Kernel names kept;
    a file mixing counters and gauges split in two (`vmstat` /
    `vmstat_gauge`); nothing a typed field already carries is sent
    twice. 23 families / 1 228 values on edgecr4ft.net. macOS: present
    and empty. `HostSampledAt.extended` dates the tier.
  - **Short-lived processes (eBPF, Linux, root agent):** families
    `shortlived` (execs, exits, exits under 1 s, lifetimes, exits
    without a seen exec, full-map refusals), `shortlived_by_comm` (the
    10 busiest names per window + `other`) and `shortlived_by_comm_max`.
    One prebuilt, CO-RE-free object embedded in `3pm` (`just bpf`
    rebuilds it); loaded once per agent start, never retried.
  - **Why a source is missing:** `extended.unavailable` —
    `permission`, `error`, and for eBPF `not_root`, `bpf_disabled`
    (`ENOSYS`), `load_failed`. Tried per read, never an euid check;
    eBPF alone checks the agent's capabilities before loading.
  - **Per process:** tree members carry `ext` (faults, context
    switches, storage and logical I/O, syscalls, threads, fds + limit,
    PSS, swap on Linux; footprint, page-ins, disk I/O, threads, fds on
    macOS), read on first sight then every 10 s, each field absent
    rather than zero; the app record gains `pss_bytes`, the tree's PSS
    (Linux, only when every member's was read). A tree reply sheds
    `ext` from its tail past its byte budget (`tree::budget_ext`).
  - **CLI:** `3pm hostmetrics --extended` prints the families (one
    table each, then the `unavailable` table) instead of the overview;
    `--json` carries everything. `3pm show` reads `mem 4.8mb RSS ·
    696.0kb PSS (4 processes, sums)` and its ` Processes` table gains
    `pss` (or `footprint`), `threads` and `fds`. With the mode off both
    render exactly as before.
  - **Link cost, measured** (edgecr4ft.net, debug build, same host and
    same 4 apps, two nodes side by side for 14 min): **777 B/s up with
    the mode on, 529 B/s off: +248 B/s (+47 %)**, far under the
    +~1 200 B/s estimated; ~30 B/s more with a root agent (eBPF
    families). Backend to node: unchanged (< 1 B/s). Stored series per
    node: 2 436 on vs 663 off (+1 228 host, +~380–545 per-process
    series seen in a 2-min window, mostly a forking test app's children
    (~220 at steady state)).
  - Wire additive only (`HostExtended`, `TreeProcExt`, link
    `RedactedProc.x`, `RedactedRecord.pss`): no protocol or link
    version bump; an older peer ignores the members. The agent module
    carries them (rebuild it with the daemon).
- **pm2 compatibility: `3pm jlist` and `3pm prettylist`.** The process
  list in pm2's shape (`pm2_env.status`, `restart_time`,
  `NODE_APP_INSTANCE`, `exec_mode`, …), as JSON or as Node's
  `util.inspect`, so scripts that read pm2's dumps read 3pm's; module
  apps left out; hidden from `--help`.
- **Module toolkit: daemon replies and shutdown signals.**
  `threepm_protocol::module::{affected, unexpected}` turn a daemon reply
  into the rows it affected, or into the one sentence every module fails
  with (`daemon refused the stop (not_found): …`, `run 3pm update` on an
  unknown reply); `shutdown_signal()` resolves on SIGINT or SIGTERM, its
  handlers installed at the call so an early signal is not lost. `serve`,
  the agent and the host agent use them in place of their own copies.
  `ErrCode::as_str` / `Display` give a code's wire name.
- **Rust SDK (`threepm-io` 0.37): metric families, removal, usage.**
  `io().gauge_family(name, Metric)` (and `counter_family`,
  `histogram_family`) declares a name with **no series** until
  `.labels(…)` makes a child — `gauge()` reports its unlabelled series
  from the first flush, a phantom for a process that only ever sets
  children. `Series::remove()` and `Family::remove_all()` stop reporting
  a series (the daemon drops it after its two-minute TTL); `io().usage()`
  gives the names and series held and what was refused, folded or
  removed, so a process that makes series from what it discovers budgets
  against the caps. The Node, Python and C++ SDKs do not have it yet
  (`packages/rust/ROADMAP.md`).
- **Sticky metric meta.** The daemon now keeps a series' unit and
  description when an entry leaves them out, and announces
  `THREEPM_CTL_CAPS=sticky_meta` to every app it starts; the Rust SDK
  reads it and sends them once per series and connection (again after a
  reconnect) instead of in every batch. An older SDK keeps sending them;
  an older daemon never sets the variable.
- **Install and link in one line.** `curl -fsSL …/install.sh | sh -s --
  link 3pmc_… 3pmk_… wss://…` installs 3pm then runs `3pm link` with
  the words after `link`; optional (`sh -s -- [version] [link …]`), and
  `THREEPM_LINK_TOKEN` in the environment links too. A refused link
  keeps the install and exits 1. **Changed with it:** `3pm link` with
  the line of the current link (same bucket, same server) now exits 0,
  `Already linked … — kept` (`"kept": true`), instead of failing;
  another bucket or server still needs `--force`.
- **Python SDK (`threepm-io`): actions and calls, with bytes.**
  `io.action(name, handler, input=…, output=…)` exposes a Python
  function to `3pm trigger`, `3pm call`, any SDK's `io.call` and the
  fleet backend; `io.call(node, app, action, params, file=…, output=…)`
  places one, on this machine or on another node of the bucket, and
  `io.serve()` blocks a worker that only answers. Same wire as the Node
  SDK — `call` / `call_ticket` / `call_reply` on the control socket, the
  bytes `PUT`/`GET` on the agent's `data.sock`, size and sha256 checked
  before a handler sees a file — so a Python process and a Node one call
  each other. Each call runs in its own thread; `call` blocks and raises
  `CallError` with the wire code. Measured Mac → fleet → a Linux node:
  a 2 MB input answered in 175 ms, 2 MB in and 2 MB out in 351 ms. Not
  yet: streams, `input.stream`, `3pm:profile`. **Changed with it:**
  `init()` without `source` now takes `main` (`pid-<pid>` for a process
  it started), as in Node — a sourceless connection cannot declare an
  action — so the series of a Python app that named no source gain
  `src="main"`.
- **A module the release installs by itself: `[module] auto = true`.**
  A bundled module marked `auto` is part of 3pm the way a built-in
  command is: 3pm does what `3pm install` would have done — copy, start
  the apps, save — without being asked, on a daemon the CLI just
  spawned (a fresh machine's first command, a boot without a startup
  service) and after a `3pm update` (how a host already running learns
  a release added one). The host has the last word and it is durable:
  `3pm uninstall <name>` writes the name into `<home>/modules.off` and
  nothing puts the module back until `3pm install <name>` forgets the
  refusal. A module that will not install is a warning, never a failed
  command; only the bundle's manifest is read for this; a 3pm that
  predates the key ignores it. `autoupdate` is the first module to use
  it.
- **`autoupdate`: a node updates itself on a custom action.** A third
  bundled module, whose whole surface is two actions — it installs
  itself (`auto = true`, above), and from then on
  `3pm trigger autoupdate update` (or the same call from the fleet
  backend, or `io.call`) downloads `install.sh` from the release site
  and runs it: the new binary replaces `~/.3pm/bin/3pm` and its bundled
  modules, and `3pm update` hands the daemon off to it without stopping
  the apps. `check` answers `{current, latest, outdated}` from
  `latest/VERSION` on the same site. `$THREEPM_CDN_URL` moves both
  elsewhere, `curl` (or `wget`) does the fetching, and the installer is
  written to disk before it is run rather than piped into a shell.
  A caller may name a version (`{"version":"0.31.0"}`), checked before
  it reaches anything that could read it as an option.
  The call answers as soon as the updater is **started** — pid and log
  path — because the update's last step restarts every module row, this
  app included: the updater `setsid`s out of the app's process group and
  outlives the restart, holds `<home>/autoupdate.lock` for as long as it
  runs (a second `update` is refused, not doubled) and writes
  `<home>/autoupdate.log`.
  **And every hour by itself**: the app reads `latest/VERSION` and
  updates the node when it is behind, to that exact version — once per
  published version (`<home>/autoupdate.last`), so an update that does
  not take never loops. `THREEPM_AUTOUPDATE_EVERY` sets the interval
  (`30m`, `90s`) or turns it `off`; the first check comes within five
  minutes of the app starting, jittered.

### Changed
- **`3pm fleet nodes` / `apps` / `inspect` no longer download the
  bucket's history**: the agent reads the bucket with
  `?history=0&view=live` (also `fleet call` resolving a hostname and
  `fleet key`), 2.1 MB → ~65 KB on a 17-node bucket, ~7 s → well under a
  second on a slow link. A backend that predates the query ignores it and
  answers whole, as before.
- **The `3pm fleet` listings draw `3pm ls`'s table.** `fleet nodes`
  (`fleet ls`), `fleet apps` and the process table of `fleet inspect`
  now use the house box style, cyan headers, a greyed frame and a
  colored state, and drop their lowest-priority columns one at a time
  until the table fits the terminal (nodes: `os/arch`, `seen`, `apps`,
  `user`, `private`, `public`; processes: `pid`, `uptime`, `inst`,
  `↺`, `mem`, `cpu`). Only then is the node id or the app name cut,
  with a `…`, never below 12 / 8 characters. The process tables gain
  `pid` and `uptime` and take `3pm ls`'s names (`name`, `status`);
  `fleet top`'s apps panel follows. A box-drawing glyph in a name a
  neighbour chose prints as `·` (in `fleet top` too): it cannot draw a
  column that is not there. The responsive table itself moved into a
  crate of its own, `threepm-table`, which `3pm ls` / `3pm show` and
  the agent share. The agent's `fleet top` moved to ratatui 0.30, so
  the agent carries one crossterm (0.29).
- **`3pm ls` and the `3pm fleet` listings are never cut in a pipe.**
  Written to a pipe or a file (`3pm ls | grep`, `3pm fleet ls | grep
  10.0.`), the table keeps every column, every host-line segment and
  every name whole, without color; it was laid out for 100 columns,
  which dropped columns from `3pm ls`. A terminal is fitted as before.
- **Table widths are terminal columns.** `3pm ls` and the fleet tables
  measure a cell the way the terminal draws it, so a full-width name
  (CJK, emoji) gets a column wide enough instead of wrapping onto a
  second line, and a cut never splits a glyph.
- **The per-app network meter dumps both families in one walk
  (Linux).** Its filtered dump of the apps' sockets goes through the
  same `TCPDIAG_GETSOCK` request as the host agent's (`sock_diag::
  dump_tcp`): the kernel walks its established table once a window
  instead of once per family, whatever the filter. Same sockets,
  checked against the two per-family dumps. Measured: a walk costs
  6.3 ms of kernel time on a 64 GB host, 1.6–2.6 ms on a 94 GB one with
  a 5.4 kernel, where a daemon with 6 apps, one of them listening,
  went 0.445 -> 0.40 s of CPU a minute.
- **The host agent reads the top processes' TCP counters without `ss`
  (Linux).** One `sock_diag` dump of the connected sockets of both
  families with their `tcp_info` — the `TCPDIAG_GETSOCK` request, which
  the kernel answers with one walk of its established table where `ss`
  made one per family (6.3 ms of kernel time a walk on a 64 GB host,
  whatever the number of sockets) — and the readable processes' fd
  tables for the holders, in the process: no fork, no exec, no text.
  The same figures by the same rules (`parse_ss`), checked against
  `ss` on generated tables and on a live connection; a kernel that
  refuses the request is asked family by family, a host that refuses
  `sock_diag` falls back to `ss` for good. Measured on a quiet 32-core
  host (5.4 kernel, ~540 processes), the agent polled every second:
  1.14 -> 0.92 s of CPU a minute, `ss` included.
- **A metrics tick hashes and lists the host's processes for less
  (Linux).** The maps a tick fills with every process of the host
  (the tree's pid index and children, the refresh ticks, the identities
  alive, the probe and backoff memos) hash with a multiply-rotate
  (`metrics/pidhash.rs`) instead of SipHash, and `/proc` is listed with
  `getdents64` into one buffer rather than `readdir`'s copy of each
  entry (the host agent's walks too). Measured on a 32-core host with
  ~1 000 processes, a daemon with 5 apps: 193 -> 160 M instructions a
  minute.
- **Extended Monitoring reads a member's counters for a tenth of the
  instructions (Linux).** Its `/proc/<pid>` directory is opened once and
  each file read relative to it into one buffer, keeping only the lines
  reported, rather than through `procfs` (a map of every line of
  `status`, `limits` and `smaps_rollup`, a `read_to_end` with its
  `fstat` and `lseek`). Same fields and same absences, checked against
  `procfs` field for field (`procfs` is now a test dependency only).
  Measured on a 32-core host, 35 members read every 10 s: 103 -> 8 M
  instructions a minute; the daemon 272 -> 193 M.
- **The daemon reads its process table from `/proc` itself on Linux,
  one file a process.** A tick reads each process it refreshes from its
  `stat` (cpu time, resident set, parent, state, name) and its `status`
  once for the uid, where `sysinfo` read `stat`, `statm` and `status`
  each time, plus an `fstat` and two `lseek`s a file, and the members'
  names again from `comm`. Same figures, computed as `sysinfo` computed
  them (start = `btime` + start ticks, cpu % over `/proc/stat`'s total
  per cpu, re-read at most every 200 ms); the pids alive are listed and
  diffed against the table as sorted lists. macOS keeps `sysinfo`.
  Measured on a 32-core host with ~1 000 processes, a daemon with 5 apps
  of 7 processes polled like a linked fleet agent: 530 -> 272 M
  instructions a minute, ~103 000 -> ~34 000 system calls a minute.
- **The daemon forwards `host_metrics` as the host agent wrote it.**
  The reply line is looked at for its tag and written to the client as
  is, rather than decoded (~50 KB, most of it Extended Monitoring's
  maps) and encoded again; a hosted user's copy is still decoded, to be
  scrubbed. Same bytes on the wire. Measured on a 32-core host, a daemon
  with 5 apps polled like a linked fleet agent (`list`, `host_metrics`,
  `process_trees` every second): 767 -> 530 M instructions a minute,
  0.33 -> 0.26 s of CPU a minute.
- **The host agent parses `/proc/vmstat` and `/proc/meminfo` for a third
  fewer instructions (Linux):** a value is parsed only for the keys it
  reports, not for each of the files' ~230 lines. Same figures.
  Measured: 11.1 -> 8.1 M instructions a minute for the two.
- **The host agent reads `/proc/diskstats` once a second, not twice
  beside `/proc/mounts` and `/dev/disk/by-id` (Linux).** Block devices
  (read as `systemstat` read them) and each mount's I/O counters (as
  `sysinfo` computed them: the resolved device's sectors × 512, the last
  value kept while the device is absent) come from the same read; the
  mounts and their devices are re-listed every 10 s, the period of the
  medium tier's disk rows. Measured on a 32-core host: fast tier 58 ->
  42 M instructions a minute.
- **The host agent parses a process's `stat` line for 40 % fewer
  instructions (Linux).** The `comm`'s closing parenthesis is looked for
  within the 64 bytes the kernel allows a name, not back from the end of
  the line's 52 fields, and the fields are split on the kernel's single
  space. Same fields read. Measured on a 32-core host with ~1 000
  processes: 43 -> 26 M instructions a minute for the parse, agent
  341 -> 329 M.
- **The host agent walks the process table once per top tick, not
  also once per medium tick (Linux).** With the top-processes table on
  (the default), the medium tier's process count and state histogram
  are taken from the top tier's walk, every 5 s, while it is younger
  than the medium period; with the table off, or its tier late, the
  medium tier walks as before. The figures a medium tick reports are
  up to one top period older (5 s by default; never more than a medium
  period). Measured on a 32-core host with ~1 000
  processes: medium tier 38 -> 10 M instructions a minute, agent system
  calls 92 k -> 77 k a minute.
- **The host agent's fast tier makes two thirds fewer system calls for the
  same figures (Linux).** Kernel files are read in one go (`openat`, one
  read, the end-of-file read, `close`) instead of `std`'s size probe
  and doubling reads (five or more for `/proc/vmstat`); per-core
  frequencies come from `scaling_cur_freq` files kept open and re-read
  at offset 0 (one descriptor per core held by the agent); interface
  counters from one `/proc/net/dev` instead of seven sysfs files per
  interface, the MAC and IPs being re-read every 10 s and at once when
  an interface appears (an address change on an existing interface now
  shows within 10 s, not 1 s). Measured on a 32-core host: fast tier
  67 -> 58 M instructions a minute, agent system calls 114 k -> 92 k a
  minute, fast tier alone 0.073 -> 0.053 s of CPU a minute.
- **The host agent walks the process table for a third of the system
  calls (Linux).** The medium tier's state histogram and the top tier's
  table read each process's `/proc/<pid>/stat` once into a kept buffer
  (`openat`, `read`, `close`) instead of through `sysinfo`, which added
  an `fstat` and an `lseek` per file, read `statm` for a resident set
  `stat` already carries, and allocated per process. Same figures, from
  the same fields (checked against `sysinfo` by test); `status` is still
  read once per process for its uid. Measured on a 32-core host with
  ~1 000 processes, `host_metrics` asked once a second: 541 -> 377 M
  instructions a minute, agent system calls 263 k -> 114 k a minute,
  agent CPU 0.557 -> 0.297 s a minute (both changes of this release);
  the `ss` and `nvidia-smi` children are unchanged.
- **The host agent assembles its snapshot when it is asked for one, not
  each time a tier lands a part.** An assembly deep-copies every part —
  the Extended Monitoring map alone is ~1 200 values — and the fast
  tier lands one a second whether anyone reads it or not. Measured on a
  32-core Linux host (callgrind, 60 s, `host_metrics` asked once a
  second as the fleet agent does): 639 -> 541 M instructions a minute
  (-15 %); with nobody asking, the copy is gone entirely. Same snapshot,
  same first-snapshot gate (identity, fast and medium).
- **A compressed `fleet cp` / `fleet ssh -C` stops trying zstd on bytes
  that do not shrink.** After two frames in a row that went raw, the
  next frames go raw untried, a span that doubles on each probe that
  misses again (up to 32 frames, 2 MiB) and closes on the first probe
  that compresses: random or already-compressed data costs zstd about
  one frame in 33 instead of every one (it cost +20 % time on a tablet
  for 50 MB of random bytes up), and text that follows compresses again
  within 33 frames. The writer's choice alone: the frame's flag already
  said raw, so older agents read it as before.
- **Daemon replies and bus events are parsed once, not twice.**
  `Response` and `Event` read their tag first (the daemon always writes
  it first) and decode the variant straight from the line, instead of
  serde's buffering of every reply into a tree before looking at the
  tag; a tag further down still decodes, through the old buffered path.
  Same JSON accepted, same values, same unknown-tag handling (checked
  against the derive by proptest). Measured (Linux, release, per
  decode): `list` of 4 rows 71.7 -> 47.1 µs, `host_metrics` 44.2 ->
  26.3 µs, `app_metrics` 6.8 -> 4.2 µs, `process_trees` 4.5 -> 3.0 µs,
  a log event through `Event::decode_lenient` 1.65 -> 0.63 µs — what
  the agent pays every tick. Nothing changes on the wire.
- **`3pm fleet ssh` costs less CPU on both ends of a flood.** The target
  reads what its pty or pipe already holds beside a read into the same
  message, queues its screen leg by bytes (about one sealed record)
  rather than by 16 messages, and seals the screen as the upload takes
  it, without a task in between; the command line writes the terminal
  from a thread of its own (stdout and stderr kept in order, all of it
  on the terminal before the process exits, `| head -1` still 141).
  Measured on a 2 M-line `fleet ssh -t -- seq` (16.9 MB, loopback):
  command line 0.35 -> 0.08 s CPU, caller's agent 0.14 -> 0.04 s,
  target's agent 0.45 -> 0.36 s; `cat` of 500 MB on pipes 576 -> about
  700-850 MB/s. Nothing changes on the wire.
- **An idle linked agent spends about a third less CPU on the node's
  state**: the host section is keyed in place and diffed by walking the
  two sorted maps instead of a lookup per member (1.04 G -> 0.71 G
  instructions a minute, callgrind). Same sections and patches, byte
  for byte.
- **`3pm fleet ssh` / command / `attach` / `-L` / `-R` / `cp`: a session
  opens sooner.** On the target, the screen leg opens beside the
  keyboard leg instead of after it (the handshake's order is kept: the
  target writes nothing before the caller's first message); on the
  command line, the screen's connection to the backend is dialled
  beside the placement (a `HEAD /api/v1/info`), and a hostname is
  resolved on `listNodes` — the node rows only — instead of downloading
  the whole bucket with every snapshot (`getBucket` on a backend
  without that route). One backend round trip or more less before the
  first byte; old and new agents and backends interoperate.
- **An app calling another node by hostname (`io.call`, the fleet
  gate) resolves the name on `listNodes` too**, not by downloading the
  whole bucket with every snapshot on each lookup not in its 10-second
  memo; `getBucket` after a 404 from a backend without that route.
- **`3pm fleet ssh -v`, and a word when LAN direct was expected but
  not taken.** `-v` prints which way the session's legs go (this host's
  agent and its socket, or the backend's relay and why); without it, an
  agent that refused the legs the backend judged LAN-worthy (too old for
  `POST /legs`, no LAN listener) is one stderr line, once per process,
  instead of a silent fallback to the relay.
- **`3pm fleet ssh` / command / `attach` / `-L` / `-R`: a busy session
  costs far fewer records, chunks and writes.** What a leg has already
  queued now leaves together, both ways: one sealed record (up to its
  65 519 clear bytes) and one HTTP chunk instead of one per message,
  in clear (`--no-e2e`) and on the LAN direct legs too; nothing waits
  for more, so an idle keystroke leaves as before. The command line
  draws a screen chunk with one write and one flush, `stderr` kept in
  its place. A full read of a pty, a pipe or a forward is sized to one
  record (it was one and a 38-byte sliver), and an unrecorded shell is
  no longer woken every 50 ms for the audit's clock. Old and new agents
  and command lines interoperate: the legs are read as byte streams on
  every version.
- **A `live` leg no longer waits seconds in the agent's sockets.** Its
  upload to the backend rides a connection of its own: 16 KiB unsent in
  the kernel (`TCP_NOTSENT_LOWAT` on Linux and now macOS; a 64 KiB
  `SO_SNDBUF` where missing), one TLS record, hyper's 8 KiB minimum
  buffer, BBR on Linux when the agent may set it. Back-pressure now
  reaches the agent's sink, whose 100 ms age rule drops what went
  stale, instead of 0.5 MB+ of frames queueing below it (a `lan` ping
  over a 5G hotspot climbed to 34 s). Files keep the bulk connection
  and its throughput. A LAN neighbour's direct leg is served the same
  way. In process, Linux, 10× more offered than the link carries: worst
  delay 380 ms, against 1.14 s before.
- **A `3pm fleet ssh` / command / `attach` session is no longer recorded
  by default: `--record` asks for it.** Without it the command line
  sends `record: false` (bound in the handshake, so the backend cannot
  turn it back on); `--no-record` is still accepted, hidden, and says
  the default. The bucket's `record_shells` off and a node's `[shell]
  record = "never"` still win over `--record`; an older 3pm that sends
  no `record` is left to the bucket, as before. The announcement is
  printed only for a session actually recorded: "this session is
  recorded (screen and command lines)".
- **The command lines go with the recording.** A session that is not
  recorded sends no `line` and no `screen` audit event: the agent does
  not even read the screen for them (no `vt100`). `open`, `exec`,
  `attach` and `close` (`lines: 0`) are still sent. The node decides:
  an older agent (≤ 0.49) still sends the lines of a session it does
  not record, so update the agents to stop them.
- **An agent of this version refuses every session in clear** (`err
  {e2e_required, "this node requires end-to-end encryption: 3pm >=
  …"}`), with no setting to relax it: a 3pm that predates the channel,
  or `--no-e2e`, cannot open a shell, a command, a copy, an attach or a
  forward on it. Update the command line first.
- **`3pm fleet ssh` tells the channel's refusals by code, not by
  words**: the backend's `ERR_NODE_NO_E2E`, and the agent's own code
  (`e2e_required`, `e2e_broken`) that the call's `result.code` keeps
  under the action code (`forbidden` / `failed`).
- **`3pm restart <app> --name <new>` moves the log files too.** They
  used to stay under the old name while the new incarnation opened
  fresh ones; they now follow the name like the pid files (the same
  step as `3pm rename`), and the restarted app appends to them.
- **Handoff manifest version 2 when a row has `tty`.** Its stream is a
  pty master, which an image that knows nothing of terminals would
  close (the app would die of `SIGHUP`): such an image refuses the
  manifest at the pre-check and `3pm update` falls back to a cold
  restart. A manifest without a `tty` row is still written as version
  1, so a downgrade without terminal apps still hands off. Every image
  from this one reads 1 and 2.
- **`vt100` 0.16.2, vendored and patched** (`vendor/vt100/`, swapped in
  by `[patch.crates-io]`, out of the workspace): a scrollback counter,
  xterm's archiving under a scroll region starting at the top, `CSI 3 J`,
  and the bytes that rebuild a whole screen (`state_restorable`);
  `PATCHES.md` lists every change and `upstream.patch` the diff to
  propose. A new dependency of `threepm-daemon` (MIT, MSRV 1.70).
- **`3pm show` names the crash tail of a `tty` app "last output"**
  (one stream), not "last stderr".
- **The daemon's metrics tick costs ~1 % of a core, not ~15 %.** The
  process table no longer holds every thread of the host (sysinfo's
  `nothing()` kept tasks on, so each partial tick refreshed thousands of
  thread ids as "gone"); listening ports come from netlink `sock_diag`
  (the LISTEN hash only; `/proc/net/tcp*` if the host refuses it, logged
  once) and only the tree members' fd tables are read — again only when
  a member is new or a LISTEN socket appears that no member is known to
  hold (at most once every 5 ticks: a stranger opening a listener every
  second no longer walks every member's fd table every second — 7 to
  20 % of a core with a member of 20 000 connections — and a member's
  own new listener shows within 5 s), plus every 10 ticks — never every
  process of the host. An fd table is opened once per walk. Measured
  on a 32-core host of 1 100 processes: 14.6 % → 1.3 % of a core, and
  a root daemon no longer pays extra for reading every fd table. The
  host agent's medium tier keeps one `System` instead of building one
  every 10 s. A listener an app shares with a process outside its tree
  (inherited, passed) now stays with the app. The whole process table is
  swept every 30 ticks instead of 10: the full sweep only resyncs a pid
  that died and was reborn between two ticks (a member reborn on the pid
  of a process older than its leader now joins its tree within 30 s, not
  10); births, deaths, groups, environments and the members' own figures
  stay live on every tick, and Extended Monitoring keeps its 10-tick
  rhythm. Measured on the same host, 4 apps, 7 min side by side: 0.59 %
  → 0.51 % of a core.
- **The host agent's memory no longer grows with Extended Monitoring
  on.** glibc's mmap threshold is pinned at 128 KiB at agent start
  (Linux, glibc), so a freed multi-megabyte block no longer raises it
  and leaves its size in an arena; the eBPF load is skipped outright
  when the agent's `CapEff` holds neither `CAP_SYS_ADMIN` nor `CAP_BPF`
  + `CAP_PERFMON` (`not_root`, no kernel BTF parsed), and parses the
  kernel BTF once instead of twice when it runs. Measured on a 32-core
  host after 15 min, anonymous memory: 17.2 → 6.6 MB (mode on, user
  agent), 17.1 → 8.3 MB (root agent), 4.5 MB unchanged with the mode
  off.
- **`3pm start <name|id|all>` restarts a managed app (pm2).** `3pm stop
  api` then `3pm start api` failed with `script 'api' not found (looked
  at <cwd>/api, then PATH)` unless a file of that name happened to be in
  the cwd — in which case it replaced the app's configuration (ecosystem
  env, `interpreter = "none"`, …) with a fresh one. A bare word (no `/`,
  no blank, not a config file, no `-- args`) that names a managed app,
  one of its ids, or `all` now restarts it with the configuration it has,
  `start` flags folded in as on `restart` (`--watch` sets rather than
  toggles); it wins over a same-named file in the cwd, and `./api` still
  means the file.
- **`stop` turns watch off (pm2 parity).** `3pm stop` clears an app's
  watch setting: a later `start <name>` or `restart` runs unwatched until
  `--watch` asks again. It used to drop the watcher only, and `restart`
  re-armed it from the spec.
- **App telemetry no longer re-derives the supervisor's sets.** After
  every message the actor re-published the leaders and re-synced the
  watchers and the crons, three passes over every row; an app's metrics,
  events, a cron's next date and a watcher's status move no row, so
  those messages skip them (any other message still pays the passes).
- **`threepm-daemon` exposes only what the CLI uses.** Its modules are
  `pub(crate)` except `bootstrap`, `handoff` and `hostagent`, and the
  items only tests used are `#[cfg(test)]`; the dead ones are gone
  (`AdoptedReaper::clear`, `quiesce::SETTLE_DEFAULT`, now
  `handoff::SETTLE_DEFAULT_MS`). One clock: `threepm_core::time::epoch_ms`
  replaces the copies in the CLI, the host agent and the agent, and
  `threepm_link::hex` the hand-written hex encoders.
- **The agent's matches on requests and replies are exhaustive.** The
  shield (`commands/guard.rs`) and the reply forwarding
  (`link/command.rs`) list every variant instead of a `_`: a new daemon
  reply, or a new remote op that signals processes, does not compile
  until it is placed.
- **A hosted app reaches the fleet like any other app.** An app the root
  daemon runs for an account of `[users] allow` may now place calls on
  other nodes — `io.call({node, …})`, `3pm call --node`, files in and
  out, and streams, which needed a node and so were out of reach too.
  The daemon used to answer `forbidden: a hosted account may only call
  an action of its own daemon`; that rule is gone, with no setting in
  its place — the right comes with being hosted. Nothing else moves:
  the bytes already had their road (the agent binds
  `<hosted home>/agent/data-<uid>.sock` for that uid alone, on the same
  token store as `data.sock`), so no wire, agent or backend change was
  needed. A hosted caller is still narrowed where it was before: it
  targets only the rows it owns, and an input it names by `path` must be
  a file of its own uid.
- **Wire protocol v2: `PROTOCOL_VERSION = 2`, and the CLI↔daemon
  contract says each thing once.** A flag day for whoever reads
  `rpc.sock` or `pub.sock` directly (`@3pm/api`, a script parsing
  `3pm ls --json`): a v2 CLI refuses a v1 daemon and the other way
  round, exactly as a `protocol: 0` daemon is refused today —
  `3pm update` and `3pm kill` stay the two skew-tolerant doors, so a
  node still crosses the generation by handoff without restarting its
  apps. Nothing of the fleet link, the backend, the control channel
  (`ctl.sock`, an app's `io`) or the handoff manifest moves: an app that
  only uses `io` has nothing to do.
  - **One log event instead of two.** `log_out` and `log_err` become
    `log {stream: "out" | "err", …}`, and `ts` — the daemon's receive
    time — is always there: a history line `3pm logs` replays from a
    file carries the live log file's last write. A subscriber names a stream the
    way it already names a process kind, `log:err` next to
    `process:exit`. The three spellings a v1 user could type keep
    working as **filter strings** for one release — `log_out`,
    `log_err` and `log_*`, the last of which covers the same three
    kinds `log*` does — so no `3pm logs` or `3pm events` wrapper breaks
    at the update. `3pm events` names the stream in its kind column
    (`log:out`, `log:err`, where it printed `log_out` / `log_err`): one
    event, but the detail column is the line itself and has nowhere
    else to say which stream it came from.
  - **One answer for `call` and `dispatch`.** `trigger` is renamed
    `dispatch` — the two are named by their role now: `call` is the
    CLI's and an app's way in, `dispatch` the fleet agent's, with its
    tickets already prepared — and both answer `called {ok, results,
    err?, code?, output?}`. A `call` names the id the fleet knows it by;
    a `dispatch` fans out to one call per instance and names none of
    them there, which is what the audit line (`proc_action.call`) is
    for. `res: "triggered"` is gone.
  - **The dead fields are gone.** The `app_metrics` request keeps only
    `id` (`since_rev` and `gen` were accepted and ignored); its answer
    loses `base_rev`, `del`, `full` and `hist[].reset`, which the daemon
    never set — so `3pm show --json` no longer prints the constant
    `app_metrics.full: true`. `res: "process"`, which no daemon ever
    sent, is removed.
  - **What was optional for compatibility is required.**
    `start.if_changed`, `restart.toggle_watch`, `delete.flush`,
    `send_signal.group`, `called.ok` and both action requests' `stream`
    are plain fields: a message that omits one is an error instead of a
    silent default. `pong` does not move — it is what announces the
    version — and neither do `ErrCode`, `Target`, the 31 fields of
    `ProcessRecord`, `DaemonInternals`, the control channel or the
    `calls` vocabulary.
  - **The Node SDK speaks v2** (`@3pm/api` 0.20.0): `connect()` refuses
    a v1 daemon with the usual `protocol_skew`. `bus.on('log:out')` and
    `bus.on('log:err')` are untouched — the bus builds them from
    `log.stream`, the way it already built `process:<kind>` from
    `process.kind` — but a listener on the *wire* name (`bus.on('log_out')`)
    must move to `log` and read `e.stream`. `LogEvent.ts` is no longer
    optional. Nothing on the request side changes.
  - The frozen fixtures are `tests/fixtures/wire/v2/`; `v1/` stays in
    the tree, and beside it a new family, `update/`: the seven requests
    and six responses `3pm update` speaks across a generation
    boundary, **frozen for good and never regenerated**
    (`docs/protocol.md` §2.1). They are the same documents in v1 and in
    v2 — which is why a v2 CLI already updates a v1 daemon with its own
    types — and two tests now say so, one on the fixtures and one
    driving the update path's real decoding. A later generation that
    breaks them fails those tests instead of failing an update in the
    field.

- **An app's crash tail survives `3pm update`.** The stderr lines a
  running app had written before a handoff were dropped when the new
  image took its process back, so a crash just after an update reported
  only what the app said since. They are kept now, ahead of the newer
  lines. An adopted row also reports `tree_rev` 0 (visible in `3pm show
  --json`) until its next metrics tick, instead of a revision naming the
  tree of the image before: the manifest carries no tree, and the new
  `boot_id` a handoff draws already tells a reader to forget the old
  `(gen, tree_rev)` pairs. The daemon's own revision counter is carried
  as before, so no revision is ever reused.

- **Link 5: one mechanism for the bytes of a result — `blob_chunk` is
  gone.** `LINK_VERSION = LINK_MIN = LINK_MAX = 5`, a flag day like link
  3 and 4: **update the nodes first, the backend last**, and restart the
  agents refused during the window. What a call carries and what it
  answers are unchanged; how the file an action returns gets there is
  not.
  - **The file an action returns is uploaded, not framed.** Before it
    sends the `reply`, the agent streams the file — raw, never gzip —
    to `PUT /api/buckets/{cluster}/calls/{call}/artifacts` with the
    bearer of its `welcome`, hashing it as it goes; the `201` answers
    `{artifact_id, bytes, sha256}`, the agent checks that digest
    against its own, and the outcome's `file` names the artifact. The
    512 KiB pieces, the four in flight, the 32 MiB link cap and the
    per-session blob budget all go with the frame. The ceiling is now
    the action's declared `output.max_bytes` (up to 256 MiB), 32 MiB
    when it declares none — the old cap, kept as the default.
  - **A result too big to travel is an error, not a fetch.** `data`
    rides inline, gzip + base64url, within one 256 KiB budget per reply;
    an outcome whose result does not fit fails `too_large` — "the
    result is N gzip bytes; a result this size is a file" — and the rest
    of the reply travels. It used to become bytes to fetch, silently.
  - **One outcome type in both directions.** `reply.res.triggered` and
    `call_result` now carry the same `Outcome {…, data?, file?,
    output?}`: what a node learns of its own call it can also be told
    about another's, since both ends fetch an artifact the same way.
    `SettledOutcome`, `RemoteOutcome`, `FileRef` and the `Payload` enum
    are gone, and `ArtifactId` joins `BlobId`, `CallId` and `Sha256` as
    a validated identifier.
  - **An outcome fails alone.** No bearer from this backend, a store
    that refuses the file (413 → `too_large`, anything else →
    `failed`, with the backend's own words), a digest the store
    disagrees about: each fails that one outcome, and the reply never
    names an artifact the bytes are not behind.
- **Link 4: one enum per direction, a three-frame handshake, typed
  identifiers.** `LINK_VERSION = LINK_MIN = LINK_MAX = 4` and the
  WebSocket sub-protocol is **`3pm.link`** — no number in the name,
  since `link_min..=link_max` is the whole of the version — so a peer
  of the previous generation is refused at the upgrade (400) or at the
  handshake (`ERR_LINK_VERSION`): **update the nodes first, the backend
  last**, then restart the agents refused during the window. Nothing
  changes in what the link carries.
  - **The handshake is three frames**: `challenge` → `hello` →
    `welcome`. The signature over the challenge's nonce rides the
    `hello` it authenticates (`hello.sig`), so there is one message to
    sign and one to verify; the separate `proof` frame is gone, and so
    are `hello.agent` (a constant) and `hello.node_id` (the
    `Authorization` header carries it and the signature binds it).
  - **`welcome.pace` replaces the three period knobs.** One mandatory
    object — `{state_s, metrics_s, trees}` with `trees` one of
    `{mode: "off"}`, `{mode: "shape"}` or `{mode: "figures", every_s}`
    — instead of `metrics_every_s` / `trees_every_s` / `state_every_s`,
    where "absent" meant three different things. Every period is
    floored at a second on decode, a mode this build does not know
    reads as `off`, and a `welcome` missing a field is refused rather
    than guessed at.
  - **A frame body never ends a session.** Each side reads the tag
    first and the body second: an unknown tag is skipped, and a known
    tag whose body does not decode is skipped, **counted** and — when
    it was a `call` — answered `reply {res: err {code: "malformed"}}`
    instead of being left to die on its deadline. `3pm agent status`
    reports the count as `frames_invalid` (`--json`, and `, 3
    unreadable frames` on the `link` line when it is not zero).
  - **Identifiers are types, not strings.** `BlobId`, `CallId` (both
    `^[A-Za-z0-9_-]{1,64}$`) and `Sha256` (64 lowercase hex) validate
    on decode, so a blob id that is not a path segment or a digest of
    the wrong shape fails its field and its frame — which is what
    retires every hand-written guard the agent ran after the fact. The
    link declares its own `LinkInput` and `OutputBlob` instead of
    borrowing the local wire's `ActionInput` / `OutputRef` (which carry
    a `path` and a `token` belonging to one host), and a `call_result`
    can no longer describe bytes the backend cannot deliver.
  - **The acceptor acknowledges.** The reader no longer sends the
    `ack`; the task that takes the call does, as its first act. A call
    a busy session could not be handed is therefore never acknowledged
    and the backend's view says so, which is the truth.
- **A node is a key, not a certificate: enrollment is one HTTP POST.**
  `3pm link` no longer opens a WebSocket to enroll: it posts the
  cluster, the token, its fresh Ed25519 public key, what the host is and
  the link versions it speaks to `POST /api/v1/enroll`, and gets back
  the node id, its state, the link version both sides share and the
  server's clock (a refusal is the backend's own `ERR_*` code with its
  HTTP status). The proof signed with the request binds the token id and
  the key — no server nonce, since what bounds a replay is the token's
  use count. Nothing is written before the answer is checked: clocks
  more than five minutes apart stop the link with `ERR_CLOCK_SKEW` and
  the delta, so a skewed clock is a sentence at `3pm link` time instead
  of a handshake that fails later. The state directory is `node.key` and
  `link.toml`: the node certificate (`node.crt`) and the cluster CA
  (`ca.pub`) are gone — the backend already looked every node up by its
  public key — and a host linked before this keeps its node id and its
  key, the two stale files being removed the first time the agent loads
  its identity. **No re-enrollment anywhere.** The link upgrade now
  names the node in the clear (`Authorization: 3pm-node
  <cluster-id>:<node-id>`, both public: a claim, not a credential) and
  the proof is verified against the row's key; an unknown node is 401
  `ERR_NODE_UNKNOWN` (`ERR_CERT_INVALID` and `ERR_CA_MISMATCH` are
  gone). `welcome` loses `cert` and `accepted_caps`: what a remote call
  may do on a host is that host's own `--cap` list, declared in
  `hello.caps` and enforced by the agent — the backend grants nothing.
  `3pm agent status` drops its certificate line and the `cert` field of
  `--json`, and `3pm fleet inspect` its certificate line.
  `LINK_VERSION = LINK_MIN = LINK_MAX = 3`: a peer that only speaks
  link 2 is refused at the handshake with `ERR_LINK_VERSION` ("update
  3pm"), so every node is updated before the backend.

- **Two field lists that had drifted from the daemon's now come from
  it.** Both were hand-kept copies of the same table; both are derived
  from `StartSpec` itself now, so there is nothing left to drift.
  - `backoff_initial`, the 0.1 spelling of
    `exp_backoff_restart_delay`, was missing from `FIELD_ALIASES`: an
    ecosystem file using it was accepted (serde resolves the alias) but
    drew a spurious "unknown field" warning on the way through
    `config/resolve.rs`. It no longer does.
  - The **Node SDK** refused nine pm2 spellings the daemon has always
    resolved — `out`, `output`, `out_log`, `error`, `err`, `err_file`,
    `err_log`, `log` and `combine_logs`. `pm.start()` answered "unknown
    option" to each; it takes them now, and `StartOptions` in
    `index.d.ts` declares them.

### Internal
- **The state that crosses the `execve` is four owned structs.** The
  supervisor actor kept the image's identity, the action calls, the
  handoff phase and the clock as sixteen loose fields; they are now
  `ImageIdentity` (`boot_id`, `lineage`, `handoffs`, `next_id`,
  `tree_revs`, `next_call` — the six counters the manifest carries, with
  the rules that restore them next to them), `ActionRegistry` (the calls
  in flight plus the three things that outlive one: the ids the control
  writers read, the paths retention must spare, the probe count),
  `quiesce::HandoffState` (the phase and the last report) and `Clock`
  (the monotonic base and `booted_at_epoch_ms`, which continue from the
  previous image rather than restarting). Counters are handed out by
  method — `take_id`, `take_call_id` — so the increment-before-use of
  call ids and the increment-after-use of row ids each have one home,
  and the tree numbering gets a borrow of that counter alone rather
  than of the whole identity. `LiveCalls` and `Pins` were already shared on
  purpose and are moved in as they are: no new `Arc`, no new `Mutex`.
  `HANDOFF_VERSION` stays 1 and neither `Handoff` nor `Row` changes
  shape; the state → manifest → state test passes with its assertions
  untouched. New `crates/threepm-cli/tests/e2e_handoff_cross.rs` runs a
  handoff **between two builds** with live apps, both ways, asserting
  the apps keep their pids and the daemon keeps its own
  (`THREEPM_E2E_OLD_BIN` names the other build; the test does nothing
  without it).
- **The per-app spec is written once.** `StartSpec` carries the whole
  field table — a `///` and a `#[spec(...)]` line per field — and a new
  proc-macro crate `threepm-spec-derive` (`#[derive(SpecTable)]`)
  generates the rest: `SpecPatch` (the 50 user-facing fields, their pm2
  aliases, their `skip_serializing_if` and `deserialize_with`),
  `KNOWN_FIELDS` and `FIELD_ALIASES`, `SpecPatch::sample`, the profile
  overlay, and the part of `apply_over` that is a fold. Eleven fields
  whose fold is a rule rather than a fold (`watch`'s pm2 shorthands, a
  size string, a cron expression, `log_rotate`'s four knobs, the
  `autorestart` / `restart_policy` pair) stay hand-written in
  `SpecPatch::fold_custom`, which the macro requires to be exhaustive:
  one left out does not compile. **No name changes**, on the wire, in
  `dump.json` or in the handoff manifest — the frozen wire fixtures and
  the CLI snapshots are untouched. Around it, the tables that used to be
  copies: `3pm __spec-schema` prints the table as JSON, `just gen`
  writes it to `packages/node/lib/spec-schema.json`, the Node SDK builds
  its `RENAMES` / `PASSTHROUGH` / `ALIASES` / `KNOWN` from it, and the
  `StartOptions` block of `index.d.ts` and the field table of
  `docs/ECOSYSTEM.md` are generated between markers. `just check` now
  fails on a stale one (`just gen-check`). The flag surface stays
  hand-written — clap's help is prose and its rules are its own — but a
  test walks `SpecArgs` both ways against the table, so a flag without a
  field, or a field claiming a flag that does not exist, fails. Two
  flags are listed as deliberate exceptions: `--time` and
  `--no-autorestart` are sugar for a value, not fields.
- **The collectors, the process trees and the metric kinds stopped
  speaking the wire.** Three places where a `threepm_protocol` type was
  the daemon's own working value, each with a translation at its edge
  now. The host collectors and the platform parsers build daemon leaves
  (`hostmetrics/leaves.rs`) that `tiers::assemble` converts once: files
  under `hostmetrics/` naming the protocol went from **9 to 3** (the
  three that are the edge). A tick's process tree is a `ProcTree`
  (`metrics/proctree.rs`) with no identity at all, so the `AppTree` that
  existed between `build` and `stamped` holding `id = gen = rev = 0`
  cannot be written any more; the supervisor's `proctree::stamp` is the
  only way to get a stamped tree, and the only place a tree is cut for a
  reply. "No tree" is `None` rather than a wire default, and an
  instance's stored tree no longer carries totals it never filled. The
  two caps are unchanged and keep their own jobs: `TREE_PROCS_MAX` (256)
  is what a row remembers — the shape `tree_rev` follows — and
  `TREE_LINES_MAX / listing` (2 000 shared out, clamped to 256) is what
  one reply carries; they only ever tie below eight rows listing
  members. A validated metric's kind is `validate::Kind`, which has no
  `Unknown`: the wire kind the daemon does not know is refused by the
  conversion at parse time, where the explicit comparison used to sit,
  and becomes a `MetricKind` again where a `SeriesDef` is built. **No
  byte on any socket changed** and `threepm-protocol` was not touched.
- **The daemon's inside stopped speaking the wire.** Twenty-nine files
  built `threepm_protocol::{Request, Response, ErrCode}` by hand, the
  supervisor's twenty-two of them through a `use super::*` re-export, so
  every rule about who may do what was phrased as a socket message. The
  actor now takes a `Command`, answers an `Outcome` or a `Refusal`
  (`crate::domain`), and the wire is translated in one place per edge:
  `SupervisorHandle::{request_as, command}` for `rpc.sock`/`ctl.sock`,
  `bootstrap::daemon_main::DaemonRpc::handle` for the `handoff` it
  intercepts, and `hostagent::proxy::fetch` for `host.sock`. The
  `SupervisorHandle::request(Request) -> Response` the CLI and the
  handoff use is unchanged. Counting production code only — test files
  and `#[cfg(test)]` modules excluded, comments excluded — those **29
  files are 9 now**, two of which are the translation itself;
  the twenty-four scattered `ErrCode::` sites of `supervisor/` and
  `users/` became **zero**, replaced by two tables in `domain::outcome`,
  one per direction. The hosted-user allow-list (`Caller::refusal`) is
  exhaustive on `Command` now — still refusing to compile until a new
  command is classified, still naming the wire tag in the message it
  prints. **Not one byte on any socket changed**: same variants, same
  fields, same codes, same messages, same event order, proven by the 73
  CLI snapshots and the `e2e` / `e2e_handoff` / `e2e_users` binaries.
  One caveat, on a path nothing reaches today: `hostagent::proxy::fetch`
  used to hand the host agent's answer back untouched, and now answers
  `err{unavailable}` if that answer is neither `host_metrics` nor `err`
  — which the agent's own server (`hostagent/server.rs`) cannot send.
  `threepm-protocol` was not touched.
- What a handoff carries is pinned field by field
  (`supervisor/tests/handoff_state.rs`): a populated actor writes the
  manifest, a second adopts it and writes its own, and the two are
  compared through destructurings of `Row` and `Handoff` with no `..` —
  a field added to the manifest no longer compiles until the read side
  says whether it survives. That test is what found the crash tail
  above.
- **What `assemble` copies is pinned field by field.** The 50 hand-written
  `host.<field> = part.<field>` lines of `hostmetrics/tiers.rs` had about
  twenty of their fifty-one targets under test; the rest (`paging`,
  `pressure`, `mem_detail`, `file_handles`, `sockets`, `conntrack`,
  `cgroup`, `vfs_cache`, `kernel`, `cpu_states`, `block_devices`,
  `load_avg`, `per_core_percent`, the swap and memory gauges,
  `cpu_frequency_mhz`) were copied by nobody's assertion, and a forgotten
  line would only have shown on a real host exposing that source. A full
  set of parts is now compared against a `HostMetrics` literal with no
  `..Default::default()`, so the next field added to the snapshot does not
  compile until someone names the part that fills it. The part fixtures
  are exhaustive the same way. The tests moved to
  `hostmetrics/tiers_tests.rs` to keep both files under 500 lines.
- **Three fields the handoff manifest wrote and nobody read are gone**:
  `opts` (the launch options travel on argv, which
  `handoff::exec::build_argv` builds from the live ones) and
  `writer.protocol` / `writer.exe`. `HANDOFF_VERSION` stays 1 and a
  rollback is safe in both directions: `Handoff` and `Writer` are
  `#[serde(default)]`, so an older image reading a manifest without them
  gets their defaults, which it never consults either.
- **Dead code out.** `hostmetrics::collect` — a second, divergent
  implementation of the tier pipeline with no non-test caller — is gone,
  its real-I/O smoke test keeping its coverage through the collectors
  directly; so is the unused `NTP_SERVER_ENV` re-export (the constant
  itself still drives `hostmetrics::slow`). `watcher::null_spawn` and
  `cron::null_spawn` are `#[cfg(test)]`: every caller was already a test.
- **Module headers measured against the code.** The `//!` blocks are the
  only map of the daemon's 38 000 lines, and an audit found the first four
  invariants it checked were all false. Every header asserting one was
  re-read against the code; the eight that lied were rewritten, and two
  more that overstated their scope were tightened. Notably:
  the supervisor actor does *not* run every input through `policy::step`
  (seven of its forty message arms feed it directly, plus the watch/cron,
  shutdown, resurrect, adoption and RPC paths — the rest is hand-written
  orchestration); the manifest's `emergency` section is not readable by
  any version, because
  `read_emergency_only` has no caller; `watcher` and `cron` raise
  `TriggeredRestart` for every row of an app, not `RestartRequested` for
  the online ones; and the supervisor does reach for a Unix-ism itself, in
  `orphans::kill`.
- **The supervisor's outward borrows have a name: `Ctx`.** A helper moved
  out of the actor needs the event bus, the actor's own queue and the
  paths on top of the state it works on, so every extraction was going to
  add three parameters and every call chain re-thread them. `Ctx` groups
  those three, `action_calls::fail_calls` takes one in place of its `bus`,
  and its five call sites build one. Nothing else moves and nothing runs
  differently. Two things the attempt settled: the actor's state maps
  (`procs`, `calls`) cannot go in it, since three of those five sites run
  inside a live `procs.get_mut(&id)` borrow and could not build it at all;
  and its constructor cannot be a method, because a `fn ctx(&self)`
  borrows the whole actor and no site holding a `&mut` on one field can
  call it. The borrow checker is still not what makes the extractions
  hard — disjoint field borrows work, and the actor's loop is a `while
  let`, not a `select!` — but it does decide the shape of the type.
- **One machine behind the file watchers and the cron schedulers.** The
  two were structural twins down to the 28-line reconcile loop; they now
  share `derived`, which holds the handle whose `Drop` cancels the task,
  the injected spawner, the `desired` set read off the rows, and the
  reconcile itself. `watcher` and `cron` keep only what differs — their
  key, how it is read off a spec, and what the task does — and the
  supervisor's two `sync_*` methods are ten lines each instead of
  twenty-eight. The invariant the pair exists for is now stated in one
  place and tested there: the set is a function of the rows' specs and
  statuses, never a bit toggled by hand, which is what keeps pm2's
  phantom watcher impossible.
- **Four clusters of the actor's own state are structs, not loose
  fields.** The fleet gate and the calls out through it (`FleetLink`),
  what the metrics tick measured of the daemon itself (`SelfMetrics`),
  the replies parked until a row settles and the armed timer count
  (`Waiters`), and the open log handles with the policy they run under
  (`LogSinks`). Each owns the few methods that touch nothing else — is
  this connection the gate, are we at the ceiling of calls out, which
  policy does this row write under — so the fields are private to the
  cluster rather than reachable from every file in the module. None of
  the ten fields crosses the `execve`, which is why they could move at
  all: the handoff manifest names not one of them.
- **Four plumbing duplicates, written once.** Nothing changes in what the
  daemon does; four pieces of it were written twice, and one of the two
  copies always lagged.
  - **One accept loop.** `rpc.sock`, `host.sock` and `ctl.sock` ran two
    copies of the same thirty-seven lines — uid check, per-listener
    gate, one task per connection joined as it goes, one drain grace at
    shutdown — differing only in the words of three log lines. They now
    call `rpc_server::accept_loop`, which takes that word and what one
    connection runs. `bus.sock` stays out of it: its drain is per
    connection, on purpose.
  - **One reader for `config.toml`.** `[users]` and `[logs]` were
    declared and read in their own modules, with their own idea of a
    problem: a typo under `[users]` was named with a suggestion, the
    same typo under `[logs]` changed nothing and said nothing. The file
    is now declared in `config_file.rs` and read there once, with the
    stricter policy for both. A section that does not decode still falls
    back to its own defaults alone, so a bad rotation value never costs
    the machine its hosted users.
  - **One durable replace.** The handoff manifest and `dump.json` each
    had their own tmp + fsync + rename + fsync(dir); only the manifest
    created the file 0600 and refused to reuse a leftover `.tmp`.
    `atomic_file::replace` is both, so **`dump.json` is now 0600** — it
    carries every app's `env`, and the policy of `users::file_owner`
    always said so.
  - **The log pump reports facts.** Building the `LogOut` / `LogErr` /
    `LogRotated` events moved out of `child_task`'s delivery path and
    onto `PumpRow`, which is what knows the row, the instance and the
    stream.

### Removed
- **The Intel macOS build.** `just publish` builds three targets
  (macOS arm64, Linux x86_64 and arm64): a quarter less work per
  release. `install.sh` on an Intel Mac stops with "Intel Macs are no
  longer supported (last build: v0.14.0)"; a shell under Rosetta on
  Apple silicon gets the arm64 build.
- **The three older node-state streams of the link, and link version 1.**
  `telemetry` (`host_tick` / `host_full`, `strip_static`, `FullPolicy`,
  the frame trimming), `app_metrics` / `resync` (`AppMetricsPatch`, the
  patch reader) and `trees` / `trees_request`, with
  `welcome.full_every_s`, the agent's sampler, application-metrics and
  process-trees tasks and their three daemon connections.
  `LINK_VERSION` is 2: an agent or a backend that only speaks version 1
  is refused at the handshake with `ERR_LINK_VERSION` ("update 3pm")
  instead of being half understood. The agent's `THREEPM_AGENT_INTERVAL_MS` /
  `THREEPM_AGENT_FULL_EVERY_S` no longer pace anything: the period is
  the backend's `state_every_s`.
- **The daemon's application-metrics diff.** `app_metrics` always
  answers the whole state (`full: true`): the per-series and per-bucket
  revisions, the histogram reset marker, the tombstones and the `since`
  filtering are gone from `threepm-daemon` (`AppMetricsState::dump`).
  `since_rev` / `gen` on the request and `base_rev` / `del` / `reset` on
  the answer stay on the wire, unread and unset, so an older `3pm` CLI
  or agent still gets a valid (full) answer. `app_metrics_rev` still
  moves only when something a reader sees moved. A whole answer at the
  series cap costs 0.28 ms (950 series, 120 KB).
- **Dead parts of the link.** The `renew` / `cert` frames (the agent's
  renewal timer and its jitter, `renewal_due`): no backend ever answered
  a `renew`, and a certificate is renewed by `welcome.cert` at the
  handshake, as before. `hello.last_seq` (always 0, never read) and
  `welcome.session` (`SessionId`, only ever logged). TLS pinning, which
  was never implemented: `Pin`, `ClusterKey`, `Disconnect::PinMismatch`,
  `pin` in `link.toml` — a pasted `3pmc_<id>.<pin>` made `3pm link`
  fail, the cluster key is now the cluster id as is. Still link
  version 2: a `renew` or a `last_seq` from an older agent is skipped,
  and a `pin` key left in a `link.toml` is ignored. An agent up to 0.5.0
  needs `welcome.session` to decode the welcome, so agents are updated
  before the backend.

### Security
- **`3pm fleet ssh -L` / `-R` / the target's forward: a transfer cut
  before its end resets the connection instead of closing it.** When a
  leg ends without the sealed `END` (a cut, a record that fails, the
  agent stopping), the local or destination connection is closed with
  `SO_LINGER` 0: its peer reads `ECONNRESET`, never an EOF it could take
  for the whole of a truncated transfer. A clean `END` still closes with
  a FIN. Nothing changes on the wire.
- **`3pm fleet cp` on a node whose account's shell is fish, csh or
  anything not POSIX: the copy's script runs under `/bin/sh` as sent.**
  The target ran every command as `<login shell> -c "<line>"`, and the
  line is quoted for POSIX: a fish (`\'` inside single quotes) or csh
  (a newline) would re-read a local file name as code. A copy's argv
  (`sh -c <one of the four fixed scripts> 3pm-cp <paths…>`, byte for
  byte) is now executed directly, same account, environment and
  directory; any other command still goes through the login shell.
- **The node's `authorized_keys` is checked like sshd's `StrictModes`
  all the way up.** A symbolic link is refused (opened with
  `O_NOFOLLOW`), and so is a file whose state directory or that
  directory's parent is owned by another user than the agent's or root,
  or writable by others or by a group other than the owner's private one
  (a sticky directory such as `/tmp` excepted): whoever could write
  there could swap the file. A refused file admits nobody, and
  `3pm link status` says why.
- **`known_nodes`: a hostname pinned under another node id is refused.**
  The pin was keyed by node id only, so a backend answering the
  hostname the person typed with another node id made the impersonation
  a first contact, pinned silently under `--accept-new`. A hostname
  typed in full (case aside), already pinned under another id of the
  same cluster, is now refused before any question, `--accept-new` or
  not: `NODE IDENTIFICATION HAS CHANGED`, both nodes named, and
  `3pm fleet known-nodes rm <pinned id>` to forget the old one. The file
  gains a fifth column, the cluster id (one file serves every bucket);
  older lines without it are compared in every cluster, and older 3pm
  builds still read the new ones. A typed prefix or id is not checked.
  `fleet cp` now pins the node as its paths name it, like `ssh` and
  `attach`.

### Fixed
- **`3pm fleet ssh -W/-L/-R` no longer dies after a minute of silence.**
  A tunnel where nothing crossed either way for a minute (an `ssh`
  through `-W` waiting on a quiet remote command, with no
  `ServerAliveInterval`) was given up by the relay and the caller exited
  255. The caller now sends an empty message on its input leg after 20 s
  of local silence, as a shell pings, until its peer's EOF; nodes
  already ignore it, so only the caller's `3pm` needs the update. The
  output leg is still silent while the destination is, so a reverse
  proxy in front of the backend still bounds a fully idle tunnel by its
  read timeout (1 h on fleet.edgecr4ft.net).
- **A file the host agent stops shipping leaves the agent's set.** The
  log agent kept a file it once tailed for as long as its path existed,
  even after the host agent's current pass called it `own`,
  `duplicate_journal`, `private` or `binary` (after `3pm update`,
  `~/.3pm/3pm.log` went on shipping). Such a verdict, by path or by
  inode, now drops the file; a file merely missing from one pass still
  stays.
- **No lines lost when one automonit ask fails.** A failed or timed-out
  `logs` action emptied automonit's files for a whole ask period, and a
  file declared again was reopened from its end. The last good answer
  now stands for three periods, and a retired file's last committed
  position is kept for an hour, so a file declared again resumes where
  it stopped.
- **A `401` no longer waits days for a token it already has.** The wait
  for a new bearer compared with the token current when the `401` came
  back, so a `welcome` that renewed it while the post was in flight was
  missed until the next reconnect. The wait now compares with the token
  the post used, and `3pm link status` says `401: waiting for a new
  token` meanwhile.
- **A busy backend's `retry-after: 1` no longer means a re-upload every
  second.** A `503` with `retry-after` is retried after the longer of it
  and the doubling backoff (at most 60 s), never sooner; a `429` keeps
  the backoff as its floor too (a `retry-after: 0` is not a loop).
- **A log path with a control character is refused.** A newline in a
  declared path could inject a line into the `offsets` file (C1 controls
  and bidi overrides are refused too: they disguise a name). Such paths
  are ignored from the host agent and from automonit, and the `offsets`
  writer never writes one.
- **Source ids that start over get a new epoch.** When the `ids` table
  was lost but the template miner's state was kept, `f1` named another
  file under the same epoch. The table now records the miner's epoch,
  and a new one is drawn whenever the two disagree.
- **A malformed `[logs]` in `link.toml` is said.** A section that reads
  as closed (an unknown key, a wrong type) ships nothing; the agent now
  warns at start, and `3pm agent status` shows `logs off:` with the
  reason (`logs_problem` in `--json`).
- **The ids table forgets the least recently seen source.** A source was
  stamped only when first asked in a run, so a full table evicted the
  longest-lived files and streams first. Files are now stamped at every
  discovery pass, app streams once a minute while they write.
- **The daemon's own logs are never shipped during a `3pm update`.** The
  host agent called a file `own` only when this host agent or its
  daemon held it; across a handoff the old agent saw the new pair as
  strangers and declared `~/.3pm/3pm.log` `ship` (seen in prod: tailed
  for 19 h). The home's `3pm.log` and `hostagent.log` (`Paths`, matched
  by path and by inode) are now `own` whoever holds them, and any file
  under the home's `logs/`, or the `logs/` of a home the daemon hosts
  (the accounts of `[users] allow` it accepts, read by its own
  `[users]` loader), is `duplicate_bus` whoever holds it, so the apps'
  output is not shipped twice during the handoff either.
- **One user can no longer push system log files out of the list.** At
  the 2 000-file cap the list kept `ship` files first, then by path, so
  a local user holding 2 000 files under `/home` evicted every
  `/var/log` file and the fixed list. Files are now ranked by the
  writer's real uid, which it cannot choose (a cgroup proves nothing: a
  hosted user's app may run in the daemon's own unit). Root's writers
  and the fixed list come first and are never cut for anyone else; then
  system accounts' writers (uid below 1 000) outside the daemon's
  cgroup; then the room left shared evenly between users, a user's
  subordinate uids (100 000 and up, `/etc/subuid`) counted as one. The
  sticky set evicts users' entries first. A walk cut at its budget
  visits root's processes first.
- **A long log-file discovery pass lands instead of starting over.** A
  pass past 30 s was dropped even when it finished later, and the next
  one started afresh, so with many descriptors no list ever landed. A
  late pass is now served when it ends (its sticky set kept), a pass
  reads at most 4 096 descriptors per process, and the walk stops at
  20 s with what it found (`partial` and `fds_capped` in the pass
  stats log line).
- **More histories are `private`.** A `history` file or directory
  anywhere under a dot-directory (`~/.local/state/bash/history`,
  `~/.local/share/zsh/history`, `~/.config/zsh/history`, `.cache/…`),
  zsh's default `~/.histfile`, nvim's `*.shada`, and a `lesshst`
  without its dot are never shipped.
- **A sticky log file no longer follows a symlinked parent.** A
  rotation (a new inode at the path) was followed when the file had
  the same owner, checking only the last component: swapping a parent
  directory for a symlink could lead it to another file. A rotation is
  now followed only when no component of the path is a symlink.
- **A misspelt key in `link.toml` `[logs]` closes it.** An unknown key
  (`file = false`, `hosts = false`, `hostlevel = "err"`) was ignored and
  left the real switch on. `[logs]` now refuses unknown keys: the
  section reads as malformed and ships nothing, and
  `LinkConfig::logs_problem` gives the reason for the agent to warn.
- **Log shipping redacts more credential shapes** (the agent's
  redaction, found by a probe in review): a Python string prefix
  (`'password': b'…'`, `u'…'`, `r'…'`, `f'…'`, `rb'…'`) no longer hides
  the value behind its letter; Go MySQL DSNs (`app:pw@tcp(…)`,
  `@unix(…)`, `app:pw@/db`, any host after a `dsn` key); the password
  argument of a PHP connection (`PDO->__construct(…)`, `new \PDO(…)`,
  `new mysqli(…)`, `mysqli->connect(…)`, `mysqli_connect(…)`,
  `mysql_connect(…)`); XML credentials (`<password>`, `<wsse:Password
  …>`, `<secret>`, `<token>`, `<apiKey>`, CDATA, `<property
  name="password">`, `<entry key="db.password">`); Kubernetes/ECS env
  lists (`name: DB_PASSWORD, value: …`, `Key`/`Value`, either order,
  other keys between); a bare `auth` key holding base64,
  `user:password` or a quoted secret (docker's `auths`, npm's `_auth`,
  Node's `auth:`, Firebase's `?auth=`); `mongosh … -p X`, a glued `-pX`
  after a MySQL or Mongo client, `zip -P` / `-eP`, `7z -p`, `openssl enc
  -k` / `-pass pass:`, `--admin-pass`; Oracle (`jdbc:oracle:thin:u/pw@…`,
  `user/pw` after sqlplus, expdp, impdp, sqlldr, dgmgrl, `rman target`,
  a `CONN u/pw` statement); Redis (`requirepass`, `masterauth`, `CONFIG
  SET requirepass`); NewRelic `license_key`, SendGrid `SG.…`,
  `Ocp-Apim-Subscription-Key`, a legacy Vault `s.…` after `vault login`.
  Kept: MySQL's `(using password: YES)`; a spaced `-p` is read as a
  password only after a Mongo client in the same command (a MySQL
  client's spaced `-p` prompts, and psql's, redis-cli's, ssh's and
  docker's `-p` is a port); digits after a DSN user (a port) or as an
  env value (`PASSWORD_MIN_LENGTH`), env names that describe a secret
  (`SECRET_NAME`, `TOKEN_TTL`), templates (`{{ .Values.token }}`),
  timestamps, URLs and digests after `auth`, `connect host/db@…` in
  prose.
- **The boot daemon answers while it resurrects.** `3pm __daemon --boot`
  (the systemd/launchd service) restored the saved list before it
  served its sockets or handled SIGTERM, and the resurrect waits for
  every dependency wave: an app under `wait_ready` that never signalled
  left the daemon unreachable (`3pm ls` hung, the agent saw "daemon not
  reachable", no host agent) for its whole listen timeout, and a
  `systemctl stop` meanwhile ended in SIGKILL. The resurrect now runs
  beside the main loop; a SIGTERM during it drains, and a dump read
  that finishes after the drain began starts nothing.
- **Log shipping, found on the live fleet**: an empty line (the kernel
  writes one after some messages) is no longer mined, counted or
  shipped as a template of its own; node and bucket ids (`3pmn_…`,
  `3pmc_…`) are public names and stay readable (only `3pmu_`, `3pma_`,
  `3pmk_` are secrets); a numeric value after a `token` key (a token
  count) and a plain word after `password` (`password for root`,
  `password is expired.`) are no longer redacted.
- **The Linux network meter no longer takes other sockets' closes when
  it subscribes.** Its destroy-notification socket joined the kernel's
  groups before its port filter went on, and a classic filter only
  judges what arrives after it: every TCP close on the host in between
  stayed queued and reached the meter unfiltered (under a busy host, a
  burst of strangers' ports at each subscription). The filter now goes
  on first, then the groups are joined. Seen as
  `the_destroy_filter_lets_our_port_through_only` failing 11 runs in 30
  beside four loopback connect/close loops; 0 in 30 since.
- **`3pm fleet ssh -C -T -- cat` read slower than it came is no longer
  twice the CPU on the target.** The screen leg's queue held one sealed
  record's worth, so a pipe read in full and the upload ran in lock
  step whenever the caller or the link was the slower end, one wake-up
  per record (tablets: target agent 0.35 -> 0.85 s CPU, 0.57 -> 0.96 s
  for 50 MB of text). It now holds four records ahead. Measured on a
  500 MB `cat` read at 200 MB/s (loopback): target agent 2.16 -> 1.19 s
  CPU, command line 1.28 -> 0.79 s. `fleet cp`, the pty flood and the
  bytes on the wire are unchanged.
- **A transfer between LAN neighbours no longer hangs on a reader that
  stopped reading.** Once a file or a leg went direct, the writer's `PUT`
  waited for the reader with no deadline; it now ends with an error
  (`502`, "no byte moved for 90 seconds") once no byte moved for 90 s,
  and the writer's body is let go of at once. A quiet session is never
  cut: its legs carry the caller's ping every 20 s and its answer.
- **A relayed `PUT` whose body the backend swallowed is answered.** The
  answer had no deadline once the body was sent, so a backend or a proxy
  that never answered held the `data.sock` connection and its permit for
  ever; the agent now gives it 4 min after body end (under the SDK's
  5 min) and answers `503` "the backend took the whole body and sent no
  answer within 240s".
- **`3pm fleet ssh -R` / `-L`: a burst of connections waits its turn
  instead of being refused "… or 8 of yours opening".** A node lets one
  caller hold 8 handshakes at once, but `-R` takes over up to 15
  connections and `-L` carries up to 16: the command line now opens at
  most 8 sessions at once (the shell's own included), the tunnels still
  up to their caps once open, and places again — up to 3 times, 100 to
  400 ms apart — a forward the node turned away in the moment its slot
  was being freed. Nothing changes on the wire.
- **SDK abandon sink (Node, Rust): an `INIT` no longer outlives the
  ceiling, and a stale head no longer takes fresh frames.** Every `INIT`
  was kept, so a stream that sent many of them grew past the 8 MiB
  ceiling without bound, and the window was judged on the queue's head:
  a stale `INIT` there dropped frames that had just arrived. As in the
  agent's sink, only the newest `INIT` is kept now (an older one is an
  ordinary frame), a frame goes past the window on its own age and
  never as the newest, and the byte ceiling holds.
- **Node SDK: a frame the wire cannot carry no longer ends the leg.** A
  payload over 1 MiB or a `ts_us` that is not an integer threw inside
  the sink, which failed the stream and the session's leg with it (and
  the stream an app had `pipe()`d in). `writeFrame` now returns `false`,
  the sink counts the frame in `stats.refused` and says it on
  `'refused'`, a piped frame is refused the same way, and
  `session.send()` counts only the frames that went.
- **Node SDK: `close()` gives a stream session's slots back.** A closed
  session kept its stream slot and its call in flight until the reply,
  which for a callee that never answered was the ticket's deadline, a
  day away: 16 closed sessions made every next `io.open` `busy`. Closed,
  it releases both at once; the call stays routed, so a late reply is
  still `result`, for `reply_grace_ms` (10 s at most, one timer shared
  with a broken leg's grace), then `result` rejects `lost`. The daemon's
  own forward (64 per host) is still held until the reply or the
  deadline.
- **Node SDK: `io.mjpeg` no longer cuts a camera image at its EXIF
  thumbnail**, whose own `FFD9` sits in the `APP1` header: the end is
  looked for past the headers, from the scan (`SOS`) on. A second
  `frames()` on a reading leg that is already read throws a clear error
  instead of waiting on bytes the first reader took.
- **A blob read whole through `data.sock` spends its token.** The token
  waited for an end of stream hyper never asks for (a body that says it
  is over after its last frame, or one empty from the start), so it
  stayed valid until its deadline and the store filled up. It is now
  spent with the last frame handed to hyper, or when such a body is
  dropped.
- **One descriptor budget for every data socket.** Each hosted user's
  socket had 512 connections of its own on top of `data.sock`'s: the
  512 are now shared, the hosted sockets get half of them together and
  a quarter each, so `data.sock` keeps its half. The `503` written past
  the ceiling is capped too (64 in flight, then the connection is
  closed unanswered), and a failed `accept` (`EMFILE`) pauses 100 ms
  instead of spinning — on the data sockets and the LAN listener.
- **A call cut at its deadline settles its local tokens.** They were
  settled on the call's last lines only; a call cut by its deadline or
  aborted with its connection left them valid until their own deadline.
- **A LAN neighbour's `404` body is read to 4 KiB at most** before its
  connection is reused; a longer, cut or late one dials again.
- **A blob's answer and refusal are read to an answer's ceiling**
  (32 MiB) by `fleet call --file/--out`, not to a blob's 2 GiB.
- **A `?pipe=1` leg opens only with the grant `POST /legs` mints.** Any
  call's write token could switch its blob to the lossless pipe path;
  the pipe now has its own grant direction.
- **`3pm update` no longer runs an app twice after a clock step.** On
  Linux the start time a spawn records (the pid-reuse guard) is derived
  from the wall clock (`btime`), so when the clock moved between an
  app's spawn and the update, the new daemon image took the live child
  for a reused pid and restarted its row beside it: the old instance kept
  running unsupervised (on a tablet, two `agent`s of the same node kicked
  each other with `ERR_SESSION_REPLACED`) and the ones that exited were
  left zombies. Adoption now trusts the kernel: a pid still unreaped is
  our own child and is adopted, whatever its recorded start time (a
  warning notes the mismatch); the emergency stop no longer withholds
  its `SIGKILL` on that mismatch either.
- **A caller whose key the node refuses reads the node's sentence again,
  behind a proxy.** The node ends its legs before its answer reaches the
  backend; in production (nginx) the cut screen leg (`error decoding
  response body`) reached `3pm fleet ssh` first, the call was read back
  once, still unsettled, and the person read `the leg failed: the screen
  leg failed: …` instead of `key_not_authorized` and its `sudo 3pm link
  authorize …`. After a leg fails, `ssh`, a command, `attach`, `cp`,
  `-L`/`-W`/`-R` now read the call back every 200 ms until it settles,
  within 3 s, and print the node's refusal when there is one.
- **`3pm fleet …` no longer warns "this node's token was taken …, past
  the backend's 10-minute grace — is the agent still running?" on a
  healthy node.** The agent writes `session.json` only when its session
  changes, so a link stable for more than ten minutes left a file older
  than the grace, read as an agent killed. The writer is now judged by
  its `pid` (`kill(pid, 0)`, `EPERM` counting as alive — a root agent),
  never by the file's age; no more writes to the disk. The warning, and
  `token()`'s "the agent is not running any more" (now naming the pid),
  only come when that process is gone.
- **A forward no longer loses the end of its answer.** Once both ways
  were closed, the node let the caller's input leg go while its output
  leg still held the answer's last bytes, a window behind its reader:
  the relay gives up a pipe whose one reader left before its writer
  finished, and took the other leg with it (`408` on the `PUT`, the
  tail cut). The node now holds the input leg until the output leg's
  `PUT` has ended (`pump_then_send`, `shell/forward.rs`), and the
  command line holds the output leg until its input leg's `PUT` has
  ended (`carry`, `fleet/ssh/forward.rs`). `-L`, `-W` and `-R` alike.
- **A `3pm` killed outright no longer leaves its module running.** A
  SIGKILL (or the OOM killer) on the `3pm` behind `3pm <module>`
  forwarded nothing, and the module ran on as an orphan — `3pm fleet
  ssh` holding its legs, its keepalives and a node's `-R` port. On
  every unix, `3pm` now hands the module the read end of a pipe whose
  write end only it holds (`THREEPM_PARENT_FD`): its death is the
  module's EOF, and the module raises `SIGTERM` to itself — its normal
  way out, so `-R` still sends its `END` and the node closes the port.
  On Linux the kernel's `PR_SET_PDEATHSIG` (SIGTERM) does it too. Only
  the dispatch path: modules the daemon runs as apps are untouched, and
  `3pm` drops the variable from its own environment, so a daemon or an
  app never inherits it. An attached `3pm serve` (a page, `--attach`)
  now treats that SIGTERM like Ctrl-C and deletes its app — before, it
  died and left the server running unheld, on Linux too.
- **`3pm fleet ssh -W` closes stdout when the destination hangs up.**
  tokio's `Stdout` never closes fd 1, and `3pm` itself kept a copy, so
  the ssh running it as its `ProxyCommand` never saw the EOF it waits
  for and hung after the remote end closed. The module now owns stdout
  (fd 1 pointed at `/dev/null` behind it) and closes it on the
  destination's end; `3pm <module>` gives up its own stdin and stdout
  to `/dev/null` once the module runs.
- **`kill`, a closed terminal or Ctrl-\ reach the module behind `3pm
  <module>`.** `3pm` forwarded only `SIGINT`: a `SIGTERM`, `SIGHUP` or
  `SIGQUIT` ended `3pm` alone and left the module running (`3pm fleet
  ssh` holding the terminal in raw mode). All four are now passed to
  the module, and `3pm` exits with its status. `SIGWINCH` is not: the
  module shares the terminal. A signal `3pm` was started ignoring
  (`nohup 3pm serve .`) stays ignored by the module, as before for
  `SIGHUP`; `SIGINT` ignored by a script's `&` no longer reaches it
  either.
- **A process tree no longer adopts a stranger for good through a
  recycled parent pid.** A process the table read long ago still named
  its dead parent's pid; once an app child was born on that pid, the
  stranger joined the app's tree and stayed (memory keeps a member).
  A row older than its parent is no longer its child.
- **Nor through a member reborn on a dead stranger's pid within one
  tick.** The table kept the stranger's row for the newborn (its start,
  its parent), old enough for the stranger's orphans to pass as the
  member's children, and memory kept them for good. Nothing is claimed
  below a row the tick did not read; a stranger adopted from such a row
  brings its children the next tick, when it is read as a member.
- **A process adopted between two full sweeps no longer reports a
  spike of cpu.** Its first refresh in a while spanned every tick it
  was left alone and read as that much work done in one tick (737 %
  measured for one busy core); it now reads 0 % until two refreshes one
  tick apart, like a newborn.
- **A resource guard ends `errored` under a slow metrics sampler too.**
  A trip was judged stable past `min_uptime` + 10 s whatever the
  sampler's period: with `THREEPM_METRICS_INTERVAL_MS` at ~5.5 s or
  more, the CPU guard's first possible trip (the second sample) landed
  past that line, and an app over its limit from the start restarted
  forever. The margin is now `max(10 s, 3 × period)`
  (`GUARD_SETTLE_PERIODS`, `settle_margin_ms`); `MemorySampled` /
  `CpuSampled` carry the period (`sample_period_ms`), which the sampler
  now takes from the supervisor (`SupervisorHandle::metrics_interval`),
  and `3pm internals` reports it.
- **Nor under `wait_ready`.** A trip was judged on the uptime since the
  spawn, but a guard samples `online` rows only: an app that never
  signals ready went `online` at `listen_timeout` (15 s, say), tripped
  past the line and looped for ever. The uptime now runs from the
  incarnation's `online` (`ProcState::online_at`, carried across `3pm
  update` as `online_at_age_ms`; a manifest without it falls back to
  the spawn).
- **A process stuck in the kernel no longer stops every app's metrics
  and resource guards.** The tick read `/proc/<pid>/environ` and
  Extended Monitoring's `io` / `smaps_rollup` in place; on a process in
  `D` state those reads never return, and the whole sweep — so every
  sample, `max_memory_restart` and `max_cpu_percent` — waited with
  them, without a word in the log. They now run on a reader thread
  under a 500 ms deadline and a 1 s budget per tick (`metrics/risky.rs`,
  `metrics/guarded.rs`): a read that misses (or panics) is retried on
  that process with a backoff of 1 to 60 ticks, not at all while its
  reader is still stuck, and the reads over the budget wait for a later
  tick; meanwhile the process is not adopted through its environment
  and keeps its last extended counters. At most four readers are left
  stuck, daemon-wide; past that both reads are off until one returns.
  A read not made is no answer: a newborn deferred by the budget is
  probed on the next tick, never made to wait out a backoff. The
  refresh with `THREEPM_PROCESS_TREE=off` and `process_start_time`
  (run on the supervisor) no longer use sysinfo's default refresh,
  which read `/proc/<pid>/io` and the tasks too; on macOS
  `process_start_time` reads `PROC_PIDTBSDINFO` instead of sysinfo,
  whose refresh reads a new pid's `KERN_PROCARGS2` (still so in the
  sweep, unguarded: the stall report is the backstop there). A sweep that still
  runs past `max(5 × period, 5 s)` is logged as an error and shown by
  `3pm internals` (`metrics sweep stalled for …`,
  `DaemonInternals.metrics_stalled_ms`, counted from when that sweep
  began, additive); the loop waits for it (one sweep at a time) and
  stays cancellable. `ExtMemo::fill`'s reader now takes the
  `(pid, start)` identity and may skip a member (`ext::Skipped`).
- **The host agent always comes back.** A wedged agent (stopped,
  deadlocked) was never restarted: only its exit was noticed, and
  `hostmetrics` said `unavailable` until `3pm update`. The daemon now
  pings it every 5 s (`THREEPM_HOSTAGENT_HEALTH_MS`, min 100 ms, each
  ping bounded by `min(3 s, period)`), from one period after its
  readiness; 6 misses in a row (~30 s) put it down (SIGTERM, SIGKILL
  after 2 s) and respawn it through the usual backoff, logged with the
  reason `wedged`; `host_agent_restarts` counts the respawn. An agent
  answering with another protocol is alive and left alone, named once
  in `3pm.log` (a respawn would run the same binary). On Linux, a
  respawn after `install.sh` replaced the binary failed every time
  (`current_exe()` reads `<path> (deleted)`): the agent and the handoff
  guardian's fallback now exec `/proc/self/exe`, the running image,
  when the path is no longer usable (`supervise::self_exe`), and the
  path otherwise, so the process keeps its `3pm` name.
- **No ghost `3pm __daemon` after `kill`.** Dropping the tokio runtime
  waited without bound for its blocking threads, so a metrics read
  stuck in the kernel kept the process alive after its lock and sockets
  were released. The daemon (fresh or after a handoff) and the host
  agent now shut their runtime down within 2 s (`bootstrap::finish`,
  `RUNTIME_GRACE`) and exit.
- **Application metrics expire even when the sampler stalls.** The
  120 s TTL sweep ran on the metrics sample only; it now has its own
  10 s ticker (`APP_METRICS_PURGE_EVERY`, `SupMsg::AppMetricsPurge`).

### Added
- **A Rust SDK: `threepm-io` (`packages/rust`).** The fourth SDK of
  `docs/app-metrics.md` — metrics, events, `notify_error` and custom
  actions — with two dependencies (`serde`, `serde_json`) and std
  threads, no async runtime. Every line it writes is decoded by
  `threepm-protocol` in its own tests, and it replays the frozen
  `limits.json` and `exphist-vectors.json` gates like the other three.
  Two things differ because a thread is not an event loop: a `metric`
  callback runs on the flush thread, and nothing holds a Rust process up
  — a short-lived program flushes before `main` returns, and a worker
  that only answers actions ends its `main` with `io().serve()`. Not
  implemented yet: `io.call`, streams and blob action inputs/outputs
  (everything on `data.sock`), the built-in `3pm:profile`, and Node's
  `builtins`. Published on the download site like the others
  (`just publish-sdk`), as a `.crate` plus a static sparse Cargo registry
  under `/sdk/cargo/`: `[registries.threepm] index =
  "sparse+https://3pm.edgecr4ft.net/sdk/cargo/"` in `.cargo/config.toml`,
  then `threepm-io = { version = "…", registry = "threepm" }`. Surface,
  bounds and every difference from the Node SDK:
  `packages/rust/README.md`; an instrumented example app in
  `../examples/sdk-example-apps/rust/`.
- **A call's file goes agent to agent between LAN neighbours.** When
  the backend judges the two ends of an `io.call` with a file to be on
  one LAN (same public address, both announcing a listener), the
  caller's agent keeps the file for the target's agent, which fetches it
  over TCP + TLS 1.3 authenticated by the two nodes' own Ed25519 keys
  (raw public keys, no certificate) instead of through the backend. The
  SDKs see nothing: they `PUT` and `GET` on `data.sock` as before, and
  the relay stays the fallback — a neighbour that does not answer
  within 300 ms per address, or a reader that does not come within one
  second of the writer, and the bytes take the backend like any other
  call's (a pair that failed is not tried again for a minute). One copy,
  never two: the rendezvous is decided under one lock, and a late reader
  is told `409` and goes to the relay. The agent opens its LAN listener
  on a port the kernel picks and announces it in `hello.direct_port`
  (never an address: those are the backend's, from the node's host);
  `direct = false` in `link.toml` turns it off, and a `3pm link --force`
  keeps what was written there. The writer reports which way it went
  (`path_report`), and the call shows `path: direct | relay`. A call's
  **input** only: its output is read after the call replied, too late
  for any rendezvous, so it keeps the relay. A **session**
  (`io.open`) goes direct both ways when both nodes are set to `all`
  (`setNodeAccess.direct`): each leg through the same abandon sink as
  on the relay, and its writer says how far it got every 5 s
  (`blob_stat`), the backend having no other counter of it. Wire: `threepm_link::direct`
  (`Peer`, `Path`, `Peer::dialable`, `lenient_peer`), `Hello.direct_port`,
  `Action.peer`, `Link::{PathReport, BlobStat}`, `LinkConfig.direct`
  (fixtures under `v2/` only grew).
- **An action declares the most its file may hold: `output.max_bytes`.**
  `io.action(name, {output: {max_bytes}}, …)` — an integer, 1 byte to
  256 MiB (`ACTION_OUTPUT_MAX_BYTES`, frozen in `limits.json`); any other
  value leaves the action unregistered (logged), since the daemon refuses
  a source's whole list over one bad declaration (`validate_decl`).
  `ctx.commitFile` stats the `.tmp` first and, past the bound, deletes it
  and throws `too_large` naming both sizes — before any `PUT`, rather than
  the relay cutting the upload far from its cause. The bound crosses the
  link as `AppAction.o` (`threepm-link`, additive: an older agent or
  backend ignores it and reserves 256 MiB, as before), so the fleet
  backend reserves what the action declares for a caller that asks for
  its file: a 2 MB screenshot costs its caller 2 MB of blob quota, not
  256 MiB. The agent carries it without a change of its own (it builds
  `AppAction` through `threepm-link`).
- **One diff engine on the link: the node's whole state as `state`
  frames (link version 2).** The agent sends **one** frame a tick
  carrying what moved in the node's state — the host, the process table,
  each instance's application metrics, each process tree, as independent
  sections cut with JSON merge patches; an empty frame is the heartbeat.
  The daemon hands over complete states and the agent is the only place
  a diff is cut; the backend rebuilds the node with one reader.
  `threepm-link::node_state` (`NodeWriter` / `NodeReader`, `StateFrame`,
  `SectionPatch`, `StateGap`, the four section shapers and their typed
  views), `Link::State`, `Link::StateResync {secs}`,
  `welcome.state_every_s`, the `state_frame` fixture family;
  `modules/agent/src/node_state.rs`. A process's `uptime_ms` no longer
  travels: its start does, once, and the reader derives the uptime from
  the frame's clock. Measured floats travel at 3 significant digits (an
  application's own values are never rounded), and what does not move
  costs nothing after the first frame. Every section goes whole at the
  start of a session and on `state_resync`; its CRC then, and one a
  minute, check the backend's rebuild. Measured on two real 10-minute
  traces, application metrics and trees included: 357 B/s on a dev Mac
  with 11 apps (the host and the table alone cost 525 as whole
  `telemetry` frames), 463 B/s on a Linux server (755).
- **`require('@3pm/api/fleet')`** — the fleet backend's HTTP API, for
  Node. A method per endpoint of `3pm-backend`'s route table, the live
  feed as `api.follow(clusterId)` (read with `fetch` and a
  `ReadableStream`, the only way Node has), `api.url(name, …)` for the
  routes that are bytes rather than JSON, and `ApiError {status, code,
  detail}`. It is the browser client generated a second time as
  CommonJS, from the same template, with the browser half — the token
  prompt, the `EventSource` reader — cut out; `just api` in
  `3pm-backend` writes both, and skips this one when there is no `3pm`
  checkout beside it. The package gains an `exports` map naming `.`,
  `./renderer`, `./fleet` and `./package.json`: the three that worked
  before work unchanged, and **deep imports (`@3pm/api/lib/…`) are now
  closed**, which nothing documented used.
- **Batteries in `3pm hostmetrics`.** A `batteries` section — one row per
  pack (state, charge, health, watt-hours, draw, cycles, temperature),
  titled with whether the host is on mains — and a `bat:` segment on the
  `3pm ls` host line, coloured low-is-bad and marked `⚡` while charging.
  Linux only, from `/sys/class/power_supply`: world-readable files, no
  root and no extra dependency (the previous answer was the
  `battery.js` example app). Wire: `batteries[]` and `on_ac` on
  `HostMetrics`, additive. The kind of a supply comes from its `type`,
  never its name, a `scope=Device` supply (mouse, headset) is excluded
  while an absent `scope` means System, amp-hour gauges are converted
  through their voltage, and the draw is carried unsigned with `status`
  giving the direction. Every reading is optional and **an absent one
  stays absent**: a full pack draws a real `0 W` and a pack with no
  sensor has no temperature — the renderers show `N/A`, never a zero.
- **`@3pm/api` streams end, feed and measure themselves.** `s.end()`
  half-closes a session on either side: it writes `END` on what this side
  sends and keeps reading until the other side's own `END`.
  `s.send(readable, { end })` feeds a source one frame per chunk and ends
  the leg with `END` when the source ends, dies or `stop()` is called
  (`await sending.done` is `{ frames, error }`, never a rejection). The
  reading side counts what the path lost: `s.stats` gains `received`,
  `bytes_in`, `missing` (the holes in `seq`, which every abandon point
  leaves, where `dropped` only saw this process's) and `discont`, and the
  reading leg's `stats.latency` gives `now − ts_us` as p50/p95/max over
  the last 256 frames (exact on a round trip, clock skew included across
  hosts). The abandon sink emits `drop` for each frame it throws away, so
  a producer can lower its rate. The wire is unchanged; a stream handler
  that returns nothing replies with the four new counters too.
- **`@3pm/api` knows its own node.** `io.node` is this host's node as
  `3pm link` recorded it (`{id, cluster_id, server, linked_at_ms}`, read
  from the agent's `link.toml`, never `session.json`; `null` when not
  linked). `io.open(null, …)` — or `'self'`, also accepted by `io.call` —
  opens the session on this very node through the fleet instead of being
  refused by the daemon, and rejects `node_offline` ("this host is not
  linked") without a link; `io.call(null, …)` stays local. A script no
  longer has to guess its node with `os.hostname()` (`MacBook-Pro-2` for
  a node enrolled as `MacBook-Pro-2.local`). A `CallError` prints its
  code: `String(err)` is `code: message`, `console.error(err)` shows
  `CallError [code]: message`.
- **`@3pm/api` keeps a serving process up, and a call waits for the
  daemon.** A process that declares an action of its own (`io.action`,
  `io.stream`) no longer exits once its script has run — its control
  socket is unref'd, so a worker that only served calls used to exit at
  once and be restarted until `errored`; the SDK's `3pm:profile` holds
  nothing, `keep_alive: false` opts out, the daemon's `shutdown` lets go;
  a renderer's io (`createRendererIo`) never holds, its window does.
  `io.call` / `io.open` placed right after `io.init()` wait up to
  `connect_wait_ms` (5 s) for the control connection instead of failing
  `node_offline` ("no control connection"), and `await io.ready()` says
  when it is up. A script whose only work is `await io.call(…)` (or
  `io.open`) no longer exits mid-call with code 0: the SDK holds the
  process while a call is in flight.
- **Documentation on the download site: `just publish-docs`** puts the
  docs of 3pm, the three SDKs and 3pm-backend on
  `https://3pm.edgecr4ft.net/docs/` as Markdown, with `llms.txt` (an index
  for LLMs: every document and every backend route, one line each),
  `llms-full.txt`, `docs/index.json`, `sitemap.xml` and a Documentation
  section on the landing page. The CLI reference walks every command's
  `--help`, the backend's routes are asked of a freshly built backend
  (`GET /api/v1/routes`), and two pages are written for the site: concepts
  and every environment variable (`distribution/site/docs/`). The site
  itself — what is online, the three commands that upload it, the landing
  page, the server and its vhost — is documented in `docs/SITE.md`.
- The host snapshot says whose box it is: `HostMetrics.user` (the account
  the daemon runs as, `getuid` → passwd, T0 identity) and
  `HostMetrics.sessions` (the accounts with an open login session, from
  utmp, T2 medium, sorted, deduplicated, at most 64). Both additive and
  elided when empty; a daemon predating them sends neither, and neither
  is ever faked where the platform has no source. On the link, `user`
  joins the static allowlist (it rides `host_full` and a change of it
  triggers a fresh one), `sessions` rides every tick.
- `3pm fleet nodes` shows `user`, `public` and `private` next to what it
  already showed: the account each node runs under, the address its link
  reaches the backend from, and its interfaces' private addresses. `—`
  for a node whose agent predates the fields. On a terminal too narrow
  for the table — and only then — the `3pmn_…` ids are cut to twelve
  characters, as `3pm ls` cuts a long app name; a pipe is never cut.
  `--json` carries them untruncated and adds `sessions`, which the
  backend sends to a node only under `fleet:read`; `3pm fleet inspect`
  names `user` on its card.
- `3pm fleet ls` is an alias of `3pm fleet nodes`.
- Fleet RPC, local wire (`threepm-protocol`): `ActionDecl.{input, stream,
  concurrent}`, the `call` request and its `called` reply, the control
  kinds `call`, `action_started`, `fleet_ready`, `forward_update`,
  `call_ticket`, `call_reply` and `forward`, the `live` message framing
  with its frozen vectors, and the shared call bounds. Additive:
  `PROTOCOL_VERSION` stays 1 and every fixture grew by addition only.
- Calls with bytes: an app or the CLI can call an action of this host with
  a file (`io.call`, `Request::Call`), and the daemon reports one owning
  answer per call (`call_ticket` then `call_reply`, or `called`).
- An authenticated fleet gate: a call that names another node of the
  bucket leaves through the agent's own control connection, which the
  daemon believes only on a per-launch secret it put in that process's
  environment.
- `ActionDecl.{input, stream, concurrent}` are enforced: the bounds of an
  input are checked before a single byte moves, and the concurrency window
  of an action, of a connection and of the daemon are three named rules.
- `action_started` restarts a handler's own budget once, so the transfer of
  a large input no longer eats it.
- Fleet RPC, Node SDK (`@3pm/api`): `io.call` / `io.open` / `io.stream` /
  `io.mjpeg`, an action's `input` / `stream` / `concurrent` declaration,
  `ctx.input` and `ctx.commitFile` over the agent's `data.sock`, the
  `live` message framing (replayed against the crate's frozen vectors)
  and the abandon sink. Additive: existing exports, declarations and
  replies are byte-identical when none of it is used.
- Fleet RPC, Node SDK: two opt-ins that spare a pass over a large input,
  both off by default and neither touching the wire. `io.call(…, { file,
  sha256, bytes })` declares a digest the caller already holds instead of
  reading the whole file to find it again (the size is checked against
  the file, the digest is taken at the caller's word and verified by the
  callee as ever); `io.action(name, { input: { stream: true } }, …)` has
  the handler read the body from `ctx.input.stream()` as it arrives,
  nothing being staged on disk. Three differences from the default,
  documented in the README and in FEATURES: `ctx.input.path` is `null`
  on a call from another node (a call placed on this host still names
  the daemon's file, and `stream()` reads it); the size and digest the
  call declared are checked at the **end** of the body, so bytes are
  unvouched for until the iteration ends; and the transfer counts
  against the handler's `timeout_ms`.
- `3pm call`: one action, one instance, one answer, with bytes allowed in
  (`--file`) and out (`--out DIR`), on this host or on a node of the
  bucket (`--node`). A call that names a node carries no file from the
  command line — the CLI has no data socket — and says which command
  does.
- `3pm trigger --file` and `3pm trigger --out DIR`: the bytes of a
  trigger. `--file` makes it a call, so it names one instance.
- `3pm fleet …`: the bucket's nodes, apps and calls, from this host's own
  link — sugar over the bundled `agent` module, with a clean refusal when
  the module or the link is missing.
- Fleet RPC, link wire (`threepm-link`): `welcome.fleet_token` (the
  node's bearer, redacted in `Debug`) and `welcome.pending_calls`, the
  bytes of a remote action (`action.{input, output, stream}`),
  `RemoteOutcome.output`, and the `call_result` frame for a call the
  node placed. Local wire: `trigger.{input, output, stream}` and
  `proc_action.{placed_by, input_bytes}`. Additive: `LINK_VERSION` and
  `PROTOCOL_VERSION` stay 1, and every fixture grew by addition only.
- **`3pm fleet` on a linked host.** `3pm fleet nodes`, `3pm fleet apps
  [node]` and `3pm fleet inspect <node> [app]` (sugar for `3pm agent
  fleet …`) read the node's own bucket with the node's own bearer, so
  the owner's account token never has to sit on a server. One request
  answers the first two; `inspect` adds the host, an app's series and
  its declared actions when the node is granted `fleet:read`. `--json`
  everywhere, `--ca-file` for a private certificate authority. A node
  is named by its `3pmn_…` id, its hostname, or an unambiguous start of
  one.
- The agent keeps the bearer of the last `welcome` in
  `<home>/agent/session.json`, now **owner-only (0600)**, with the
  instant it arrived: a command run while the link has been down longer
  than the backend's ten-minute grace says so before it tries.
- `3pm fleet top`: the bucket live in two panels (nodes, processes), from the
  backend's event stream, redrawn once a second; `q` / `Esc` / Ctrl-C quit.
  Needs `fleet:list` only.
- Fleet calls with bytes: the agent serves `agent/data.sock` (HTTP/1.1 on a
  Unix socket, per-call local tokens, one blob one direction) and relays to
  the backend without holding anything, opens an authenticated fleet gate on
  the daemon's control socket, places a node's calls on `nodeAction`, and
  hands the result back as `forward_update`. `3pm agent fleet call` places one
  from the command line, `--file` / `--out` included. `--out` builds its
  file through a temporary only this call can have created, so neither a
  link left at that name nor the leftover of a crash is written through.
- Live streams across the fleet: `io.open` on one node and `io.stream` on
  another exchange framed messages through `agent/data.sock` — a chunked
  `PUT` out, a `GET ?live=1` back, relayed to the backend as they arrive.
  The agent cuts what it relays on message boundaries, never mid-message,
  and drops its oldest ordinary messages instead of queueing them when the
  backend is behind, keeping the `INIT`, the last `KEY` and the `END` a
  later reader joins on — never the only message it holds, however big.
  A session's ticket names both legs (`in` and `out`, no `put`) and its
  tokens live as long as the session, up to 24 hours. A chunked `PUT`
  whose bytes are not `live` messages, or that stops in the middle of
  one, is refused `422`; a session that names an `output` of its own is
  refused before it is placed (its answer is its second leg).
- `sdk-example-apps/fleet-call/camera.js`: the streaming half of the
  example — `ffmpeg -f mjpeg -flush_packets 1` into `io.mjpeg` and
  `io.open`, with `vision.js` answering one summary per image on a
  `watch` stream action.
- `3pm cd <app>`: the app's working directory. On a terminal it opens
  `$SHELL` there (`exit` returns); piped or in `$(…)` it prints the path,
  so `cd "$(3pm cd api)"` moves the current shell; `--json` gives
  `{"name","cwd"}`. One name or id: `all` and patterns are refused.

### Changed
- **`3pm update` restarts every internal process, every time.** The
  apps are what a handoff keeps running; 3pm's own processes are not.
  The daemon was already re-executed and the host agent already
  replaced at every handoff; the module apps — the fleet agent, the
  `3pm serve` sites, a module's `[[apps]]` — were restarted only when
  the bundle's version had moved, so a same-version `update` left them
  on their old process (and the dev loop ended with a manual
  `3pm restart agent`). Every **live** module app is now restarted once
  the new daemon serves: same row, same spec, same stamp, nothing
  reconciled. A module app whose every instance is stopped stays
  stopped; the cold path, which has just restarted everything, still
  restarts only the rows behind their stamp; `--skip-modules` leaves
  them all alone. The price: at every `update` the fleet link drops for
  the time of an agent restart (it comes back on its own, no new
  enrollment) and a served site blips. The report says
  `Module [agent] 0.10.0 — 1 app(s) restarted`; in `--json`,
  `modules[]` is now there at every handoff that has a live module app,
  with `from == to` for a module restarted as it is.

### Fixed
- **Process trees during a handoff.** `3pm tree` (the `process_trees`
  request) was refused with `handoff_in_progress` while the daemon
  quiesced, although the client re-sends it as a read; the daemon now
  serves it like every other read. Both sides classify requests through
  one exhaustive match each (`Request::is_read_only`,
  `Command::is_read`), pinned to agree by a test.
- **`completions` and `__man` without a usable home.** Like `validate`,
  they no longer resolve `THREEPM_HOME` first, so a home too deep for a
  socket (or none at all) no longer fails them. `validate` now exits 1
  by returning, not with `process::exit`, so its output is flushed.
- **Modules resolve the home like the CLI.** `3pm link`, `unlink`,
  `fleet`, `serve`, `autoupdate` and the agent itself used `$THREEPM_HOME` or
  `~/.3pm`; they now use `client::resolve_paths`, which also finds the
  home the root daemon hosts for the account when run directly.
- **The agent survives a poisoned lock.** Its data socket, tokens,
  hosted sockets and `fleet top` model took their mutexes with
  `.lock().expect(…)`: one panic under a lock made every later caller
  panic too. They go through the poison-tolerant `lock()` now.
- **An app started after a `3pm update` can be stopped again.** The
  handoff blocks SIGHUP/SIGINT/SIGUSR2/SIGTERM around its `execve` so a
  signal arriving in the window reaches the new image; the new image
  unblocked them on the thread that boots, but a signal mask is per
  thread and survives `fork` **and** `exec`, so every tokio worker built
  before that kept them blocked — and every app forked from one of those
  workers inherited the blocked mask. `3pm stop` sent the app its
  `kill_signal` (SIGINT by default), the app could not receive it, and
  the daemon SIGKILLed it `kill_timeout_ms` later: no flush, no clean
  socket close, +1.6 s on every stop, restart, reload, redeploy and
  `3pm link --force`, and an app listening on SIGHUP or SIGUSR2 silently
  stopped reacting. Nothing showed it but the daemon log (`signal=9`)
  and the delay. The spawner now clears the mask in the child, between
  `fork` and `exec` — a supervisor hands its children a clean mask
  whatever thread forks them, and the standard library deliberately
  passes the parent's on. **The host agent was hit the same way**, by
  its own launcher: respawned by the new image at every handoff, it was
  reborn with SIGTERM blocked, so the *next* `3pm update` waited its 2 s
  grace and SIGKILLed it (`host agent ignored SIGTERM; killing` in the
  daemon log, once per update). Both spawn sites now share one helper
  (`sigmask::clear_in_child`). A daemon started cold was never affected,
  macOS was never affected, and Node apps never were either (Node resets
  its own mask at startup) — Rust, Python and shell apps were. 3pm's own
  processes heal at the update that brings this (they are restarted by
  it, see above); an app already running with the blocked mask picks the
  fix up when it is next restarted, and that stop is one last SIGKILL.

### Changed
- **One version for the whole release.** `just publish` writes 3pm's
  release version into the agent (`Cargo.toml`, `module.toml`), the
  Node, Python and C++ SDKs (`package.json`, `pyproject.toml`,
  `__version__`, `CMakeLists.txt`, the install lines of the docs) and
  the backend checked out next to 3pm (`Cargo.toml`, the version of its
  generated clients), so every release ships all three SDKs and the
  backend it deploys reports the same number. The agent and the backend
  used to report 0.1.0 whatever the release; the SDKs went from 0.1.x
  to the release number.
- **A node awaiting approval retries every 5 s at most** (was 1 min,
  growing to 10 min): approving it on the dashboard now brings it
  online within 5 s instead of up to 10 min later.
- Node SDK types: `ActionInputFile.path` is now `string | null`. It is
  `null` only for an action that declared `input: { stream: true }` and
  was called from another node — every other path is unchanged, and no
  consumer in this repository dereferences it unguarded. A TypeScript
  consumer under `strictNullChecks` that reads `ctx.input.path` without
  a check will need one.
- **The agent (`3pm link`) runs on four tokio workers and a blocking
  pool capped at sixteen**, instead of one worker per core — 33 threads
  on a 32-core host, 5 at rest now. The blocking work the module writes
  itself (`commands::pack`, the gzip of a call's result) is one bounded
  pass per call, but the pool is shared with the libraries and DNS lives
  there: `lookup_host` and hyper-util's resolver are `spawn_blocking`
  calls that cannot be cancelled, so sixteen keeps a burst of slow
  lookups from queueing every later packing behind them. The constant
  carries that inventory and a test fails if a new caller appears
  outside it. Same change, and the same absence of cost, as the host
  metrics agent's 38 threads to 8. The resident set never depended on
  it: `MALLOC_ARENA_MAX=2` is what settled that.
- **`@3pm/api` is no longer published on the npm registry**: it ships
  from `https://3pm.edgecr4ft.net` with the other SDKs (`just
  publish-sdk`), and `"private": true` makes a stray `npm publish` fail.
  The 0.1.0 already on npm stays there, not updated.
- **Release binaries use thin LTO, and `just publish` builds everything
  in parallel**: fat LTO optimised all of `3pm` on one core, about a
  minute per target, and the four targets and their three workspaces
  built one after the other. The twelve builds now run at once, each in
  its own `target/dist/<triple>/<workspace>` with its log in
  `target/publish/logs/`; the first failure stops the others and prints
  its log.
- **`3pm link` needs no server address**: `3pm link <cluster-key>
  <token>` links to the hosted backend, `wss://fleet.edgecr4ft.net`. A
  third positional still names another one, and `THREEPM_LINK_SERVER`
  wins over both, so a machine or a provisioning script points its links
  elsewhere without touching the pasted line.
- **Python apps log without delay**: the daemon sets `PYTHONUNBUFFERED=1`
  for an interpreter named `python*`, a `.py` script run by its shebang
  (pm2), or a quoted command line starting with one (`"python3
  app.py"`), under the app's `env` (`PYTHONUNBUFFERED = ""` there turns
  it off). A `print()` without `flush()` used to reach the log
  8 KiB late.
- **A config file's script without an interpreter must be executable**,
  checked when the file is read (`validate` reports it) instead of
  failing at spawn as `errored` — the rule `3pm start <script>` already
  applied.
- **`app.config.js` is a config file** (pm2): `3pm start x.config.js`
  evaluates it instead of running it as a script.
- **An app's `cpu` and `mem` are those of its whole process tree** — the
  spawned pid and every descendant (children by parent pid, orphans by
  the leader's real process group, the classic double-fork daemon by
  the `THREEPM_ID`/`THREEPM_GEN`/`THREEPM_CTL_SOCK` it inherited and
  the daemon's lineage `THREEPM_BOOT` — so a survivor of an earlier
  daemon of the same home is never adopted —, the cgroup leaf under
  `cgroup_enforce`, the members already seen for as long as they live,
  and every descendant of a member; never a process older than the
  spawned pid, never 3pm's own daemon or host agent, which strip the
  identity variables from their environment however they were
  launched), not the spawned pid alone: an `npm run dev`
  that starts Electron and its helpers reads `62.2% / 3.0gb` instead of
  `0% / 71mb`. `listen_ports` is the union over the members. Memory is
  the sum of the members' RSS, an upper bound when they share pages.
  **The guards judge the tree too**: `max_memory_restart` and
  `max_cpu_percent` fire on the sum, so a child that grows recycles the
  app — per app, `limit_scope = "leader"` (ecosystem file) keeps them
  on the spawned pid, and an app saved by an earlier 3pm (`dump.json`,
  the `3pm update` manifest) reads back as `leader`, so an upgrade
  never widens a limit (`tree` is the default of a new `start`);
  `THREEPM_PROCESS_TREE=off` on the daemon restores the old sampler for
  every app. The tick sweeps the whole process table on its first run
  and every tenth tick; in between it refreshes only the pids born or
  gone since, the leaders, the members and 3pm's own, so a busy host
  costs the sampler its apps' processes, not the host's (one `sysinfo`
  pass, cpu and memory together, off the runtime, no stat file held
  open — a full sweep is about 12 ms for a thousand processes, priced
  by `3pm internals` as `last metrics tick`); a daemon with nothing
  running does not sweep. Never a thread, a zombie or, on macOS,
  another user's process; macOS withholds the environment of Apple
  platform binaries, so a `/bin/bash` that daemonizes before the first
  tick is not adopted (a `node` or `python` that does is, and so is the
  `bash` once it `exec`s one: an unreadable environment is read again,
  1 then 2, 4… up to 60 ticks apart), unless it was already a member.
  `docs/metrics.md` → *Process trees*.
- **Two more label keys are reserved for applications**: `proc` and
  `pid`, the dimensions the fleet backend queries an app's process tree
  by (one series per member, `subprocess.*`). Refused like the six
  below, by the SDKs and by the daemon; rename such a label.
- **Six label keys are now reserved for applications**: `core`, `iface`,
  `blockdev`, `sensor`, `gpu` and `array`, alongside the fifteen that
  already were. They are the dimensions the fleet backend queries a
  host's metrics by (CPU per core, per interface, per block device, per
  sensor, per GPU, per RAID array), so an application's own label of the
  same name would shadow them. An app that used one of these keys sees
  it refused like any other reserved key — by its own SDK, as a silent
  no-op series that `THREEPM_IO_DEBUG=1` explains, and by the daemon
  behind it (`app_metrics/limits.rs`, mirrored in the Node, Python and
  C++ `limits` and pinned by a test); rename it. `device` and `cpu` were
  deliberately *not* taken — they are the two an application is most
  likely to want for itself.
- A control connection's outbound queue holds 32 messages (was 8) and one
  source may run 16 calls at a time (was 4, and was one per action —
  `ActionDecl.concurrent` now says how many).
- The action listing of `3pm trigger <app>` and `3pm show` gained two
  columns, `kind` and `input`, and their `--json` gained the `stream`,
  `concurrent` and `input` fields every declaration now carries.

### Fixed
- **`--interpreter /bin/sh` runs `/bin/sh`.** A path given as the
  interpreter was resolved to the file it links to (`/usr/bin/dash` on
  Ubuntu). On Alpine, `/bin/sh` is BusyBox, which picks its applet from the
  name it is called by: called as `busybox`, it did not start the app. A
  venv's `.venv/bin/python` became the system Python, without the venv's
  packages. The path is now made absolute and its links are kept.
- **Docker containers are no longer counted as disks.** Each running
  container added an `overlay` mount the size of the disk under it (five
  extra 937 GB disks on the build host), which inflated the dashboard's disk
  space and added series at each new container. Overlay mounts are left out,
  except the one on `/` (3pm running inside a container).
- **Linux: another account's process no longer joins an app's tree
  through its environment.** The account check of the environment rule
  compares the process's uid with the row's, but the process table never
  asked Linux for uids (sysinfo reads one only on request), so every
  process passed it: under a root daemon hosting users, one account's
  process carrying another's `THREEPM_*` variables was counted in that
  app's tree, and its memory against the app's `max_memory_restart`. The
  uid is now read, once per process.
- **The LAN direct path no longer runs dry after 1 024 sessions a day.**
  An agent kept each direct transfer's grant and rendezvous slot until the
  call's deadline — a day for a session — in tables of 1 024, so past that
  every session silently took the relay. A leg now gives its room back when
  it ends (a 60 s tombstone still answers a late reader `409`), a call's end
  releases what it never used, and a full table is logged once a minute.
- **The LAN listener serves the LAN only.** It listened on every address;
  a connection from anything but a private address (`is_private_ip`) is
  now closed before the TLS handshake, so a node with a public IP exposes
  nothing.
- **A reader gives a neighbour one hold, not five seconds.** Behind a
  firewall that takes the TCP connection and never delivers it (macOS),
  a live leg lost 5 s of frames before its fallback; the whole dial —
  addresses raced 100 ms apart, TLS, head — now ends with the writer's
  1 s hold, and a reader that came before the writer knew it keeps asking
  for that hold instead of once. A writer no longer blames a neighbour
  that asked before it knew of the blob.
- **A hosted user's app reaches the agent's data socket.** Under a root
  daemon with `[users] allow`, an app run as a hosted user could neither
  push nor fetch a blob: the agent's only socket is root's
  `agent/data.sock` (0600, in a 0700 directory), and the
  `agent/data-<uid>.sock` the SDK looks for first was created by nobody.
  The daemon now creates `agent/` in each hosted home (root:gid 0750) and
  names one socket per hosted user on the fleet gate each time it opens
  (`ControlOut::HostedSocks`, `hosted_socks` on `ctl.sock`, additive; the
  whole list, empty when it hosts nobody); the agent binds each for its
  `uid:gid`, 0600, on the same server as `data.sock`, and closes one the
  list no longer names (a handoff re-reads `[users]`). An action of a hosted app can now return a file with
  `output: true` without a relay or `THREEPM_DATA_SOCK`. An older agent
  skips the line; an older daemon never sends it.
- **Node SDK: a stream session whose leg broke settles its `result`.**
  When a leg of an `io.open` session breaks under the caller (the link
  fell, the backend restarted), `session.result` waited for the call's
  reply until the ticket's deadline — a day for a session — so an
  application that reopens once `result` settles stayed stuck. The reply
  is still the answer when it comes; without one, `result` now rejects
  `lost` `reply_grace_ms` after the break (default 10 s,
  `OpenOptions.reply_grace_ms`). A session the application closes itself
  is not a break.

### Changed
- **`just publish` publishes the SDKs too**: after the 3pm tarballs it
  runs `publish-sdk`, which ships each SDK whose version is not online
  yet (`--dry-run` is passed on; `--force` is not — re-uploading an SDK
  version stays `just publish-sdk --force`). `@3pm/api` is 0.1.3.
- **The `state` stream, after its review** (before its first release).
  *Daemon:* a histogram whose scale alone changed while it held no
  bucket (every observation in the zero bucket) did not move
  `app_metrics_rev` — the rule a reader of full states lives by is "the
  revision moves exactly when the state does", and the agent would not
  have asked. Fixed, with the property test that holds the rule over
  random batches, source removals, TTL sweeps and resets
  (`app_metrics/state_prop_tests.rs`, the successor of the link-reader
  proptest that went with the daemon's diff). The gate the daemon's
  diff was removed behind is now measured on a realistic registry
  (labels, unit, description, histograms): a full dump at the series
  cap blocks the supervisor 0.71 ms on the build host, 0.16 ms on an
  M-series (`measure_a_full_dump_on_the_actor_thread`, ignored; figures
  in `3pm-rd/rd/old/bench/host-patch/results/daemon-full-dump.txt`).
  *Agent:* a section's CRC falls at its own moment of the minute
  (`paces::crc_phase_ms`) instead of every moving section being
  serialized and checked on the same tick; a tick no longer clones each
  patch to build its frame (`p` moves out) nor writes one to measure it
  (`node_state::json::json_len`); the host section is shaped in one
  pass. *Link:* `NodeReader::retain_gen` (a backend forgets what an
  agent that restarted did not bring again — it never says `gone` for
  it), `ProcsTable` (the table decoded once, read at any clock),
  `NodeWriter::holds`. `link-protocol.md` says why the sub-protocol
  stays `3pm.link.v1` and in which order a fleet crosses link 2 (the
  nodes first, out of band; the backend last).
- **Fleet calls under load, in the agent.** A command past the four in
  flight now waits for a permit until its own deadline instead of being
  refused at once (`commands.rs::CallCtx::admit`; a session is still
  refused at once). A `call_result` that arrives before the call's ticket
  was written is kept (up to 6 s, 64 calls) and follows the ticket out,
  where it used to be dropped and the caller waited 30 s
  (`fleet_gate/waiting.rs`). A `replied` result with no outcome and no
  error reads `lost`, not `ok`. Commands reuse their `rpc.sock`
  connections (`commands/daemons.rs`) instead of dialling and pinging the
  daemon per call and logging a line each time, and the HTTP client
  drops idle connections after 30 s, before a proxy closes them.
- **`@3pm/api` reads `link.toml` once per version of the file** for
  `io.call('self')` / `io.open(null)`, not on every call.
- **A `live` stream relayed by a macOS agent is no longer capped at
  6-7 MiB/s.** The agent pinned its relay sockets' `SO_SNDBUF` to
  128 KiB off Linux, which switches macOS's send-buffer auto-sizing off:
  each relayed `PUT` carried the buffer over the round trip, about a
  sixth of what the path held (40 MiB/s measured with the kernel's own
  sizing). macOS now keeps the kernel's buffer; `SO_SNDBUF` stays where
  there is neither auto-sizing worth keeping nor `TCP_NOTSENT_LOWAT`
  (Windows, the BSDs).
- **The agent's live sink drops frames by age, not only by bytes.** A
  queued ordinary message older than 100 ms (`QUEUE_AGE_MAX`, the SDK's
  and the backend's own window) is dropped instead of sent, checked on
  push and again just before a send. It used to wait out a 512 KiB
  queue — seconds of stale images near the link's cap. `INIT`, the last
  `KEY`, `END` and the newest message are still never dropped, and the
  byte bound stays as the memory ceiling.
- **Ending a stream no longer throws or crashes in `@3pm/api`.**
  `s.close()` during `for await (const f of s.output.frames())` threw
  `ERR_STREAM_PREMATURE_CLOSE`; the iteration now returns, and a failed
  call still throws its failure. `io.mjpeg(readable)` emitted `'error'`
  when its source failed or closed mid-image (`ffmpeg` killed), which
  killed an application with no handler; it now ends, drops the partial
  image and keeps the reason on `out.source_error`. Forwarding a received
  frame with `writeFrame(f, { key: false })` kept the `KEY` it came with:
  a named flag now decides its own bit, unknown bits still carried. A
  `writeFrame` after the leg ended is ignored (`false`) instead of failing
  the session.
- **One `live` stream was capped at about 4 MB/s over a link with
  latency.** The agent's relay set a 128 KiB send buffer on the socket
  it carries a blob over, to keep a frame's latency at 20 ms instead of
  800. Setting `SO_SNDBUF` does more than choose a size: it turns
  Linux's send buffer auto-tuning off for the life of the socket,
  pinning the bytes that may be in flight at twice what was asked.
  256 KiB over the 60 ms round trip of a fleet link is 4.37 MB/s, and
  4 MB/s of an offered 5 is what a hundred 50 KiB images a second
  measured under `tc netem delay 30ms`, the rest thrown away by the
  backend's window behind a queue that stood at 200 ms. On Linux the
  buffer is now the kernel's to size and `TCP_NOTSENT_LOWAT` alone
  keeps the queue shallow, which is what it is for: it bounds what is
  queued **unsent**, not what is in flight. Measured end to end under
  the same `netem`: p50 216 → **61.0 ms** at a hundred images a second
  (p90 61.3, floor 60), 261 frames lost → 10 of 1 485, and nothing lost
  at thirty. On the socket, 2 626 560 bytes of send buffer where there
  were 262 144 — up to about 43 MB/s a stream where the path permits it,
  a bound read off the socket rather than measured. Where there is no
  such knob
  (macOS, the BSDs, Windows) `SO_SNDBUF` is unchanged and so is the
  latency it bought. The backend carries the same change on the sockets
  it accepts; a fleet only half updated stays capped by the other end.
- **`3pm fleet …` no longer calls a healthy node stale.** The agent read
  the age of its fleet bearer whatever the state of the link, but a
  bearer is minted at a handshake and is good for as long as that
  session lasts — so any node linked for more than ten minutes had
  `this node's token was taken … ago, past the backend's 10-minute
  grace — is the agent still running?` printed above an answer the
  backend had just accepted. The token's age is now read only when
  nobody is renewing it: the link is down, or the session file has been
  left behind by a killed agent. A live link with a current file says
  nothing, and the warning for a genuinely dead one is unchanged.
- **The host metrics agent no longer grows by a task per connection
  served.** Each accept loop (`host.sock`, `rpc.sock`, `ctl.sock`) kept
  every finished connection task in its `JoinSet` instead of joining it,
  so a linked node — whose agent asks for `HostMetrics` once a second,
  which the daemon answers by reconnecting to `host.sock` — leaked about
  17 MB an hour: `3pm __host-agent` reached 860 MB of resident memory
  after 45 hours. The loops now join their connections as they go. The
  same growth was reachable from a **hosted, non-root account**: its
  connection cap only ever counted the *live* ones, so a connect/close
  loop on its own socket grew the root daemon's memory without bound.
- **The host metrics agent runs on two worker threads.** It took one per
  core — 37 of them on a 32-core host, each with its own allocator arena
  whose high-water mark never came back — to run four tickers and answer
  one connection a second. Collectors were already on the blocking pool,
  which is sized apart, so nothing samples any slower.
- **The agent runs with `MALLOC_ARENA_MAX=2`** (set on its own app spec
  unless the operator set one): glibc hands every contending thread an
  arena and never returns its pages, which read as a resident set that
  followed the bytes relayed; with two arenas and 64 KiB socket buffers
  the agents stay within a mebibyte across a hundred megabytes.
- **The agent's fleet gate no longer keeps one finished task per settled
  call.** Two of its three spawn sites (a settled call, a `welcome`'s
  catch-up) were never joined while the connection lived, so a node that
  placed nothing while the link kept settling calls grew by one dead task
  each time; all three now go through one reaped set (`fleet_gate/tasks.rs`),
  the same defect and the same shape as the daemon's connection loops.
- **An agent counts `live` sessions apart from plain commands.** A
  session holds its permit until the stream closes, so four cameras on
  one node made the fifth `unavailable` with nothing else running; the
  sessions now have the daemon's own budget of 16
  (`STREAMS_PER_DAEMON_MAX`) and short commands keep their four. Nothing
  on the wire moved.
- **A session refused after its ticket ends at once** in the Node SDK,
  with the code and sentence the backend sent: `io.open` answers on the
  ticket, and a `forbidden` or `unavailable` arriving with the reply
  used to leave the reading leg open until its idle guard fired a minute
  later.
- **A `PUT` of a file is no longer given up on after ten seconds.** It is
  answered when its body ends, so the Node SDK gives it no header budget
  at all (it inherited the ten seconds of a `GET`'s head, which capped a
  relay at about 125 MB over a 30 ms link); sixty seconds of silence on
  the way out ends it instead. Every write, and every read of a blob the
  caller does not know settled, now takes a connection of its own rather
  than one of the eight keep-alive sockets, so eight slow transfers no
  longer make the ninth wait two minutes.
- **A body the relay has parked** — the backend's window full, no reader
  yet — is no longer cut with a `408` on the first poll that follows: the
  agent's idle budget measures the source's silence, not the relay's wait.
- **The agent's relay reuses its connection after a `PUT`**: the `201`'s
  body was left unread, so the warm connection was thrown away and every
  upload paid a fresh TCP and TLS handshake. Reading it is bounded by the
  head budget (8 s): a backend that sends the head and never the body
  costs the reuse, not the caller's request.
- **A backend name that resolves to several addresses** no longer spends
  the whole dial budget on the first one that does not answer: each gets
  a quarter of a second before the next is tried.
- **The agent's live sink rebuilt a message in time quadratic to its
  size** (a one-mebibyte message crossed thirty-two mebibytes of copying)
  and its HTTP buffers were capped at 16 KiB, which cut a hundred
  megabytes into six thousand frames; both fixed, the head block staying
  at 16 KiB and refused `431` past it.
- **The C++ SDK builds inside another project**: `THREEPM_IO_TESTS`
  defaulted to `ON`, so a parent's `add_subdirectory` or `FetchContent`
  compiled the SDK's tests in its own tree (and failed on an archive
  without `tests/`). It now defaults to `ON` only when `packages/cpp` is
  the top-level project; `packages/cpp/tests/consumer` checks it in `just
  sdks` and CI.
- **A root install no longer leaves files owned by uid 501**: the
  release tarballs recorded the Mac user that built them, and
  `install.sh` run as root restored that owner. `just publish` now
  archives as `root:root` and `install.sh` extracts with `tar -o`, so
  the files belong to whoever installs whatever the archive says.
- **Linux binaries load on glibc older than 2.34** (Debian 11, Ubuntu
  20.04, RHEL 8): the handoff guardian called glibc's `close_range()`
  wrapper, a hard symbol dependency; it now issues the raw syscall and
  keeps its one-by-one fallback for kernels before 5.9.
- The handoff's emergency path decides what to stop from the kernel
  rather than from the process table: an app still unreaped is alive and
  still ours, which a reused pid never is. It used to read the process
  start time, a read that can fail under load and then left a live app
  running beside the one the fresh boot resurrected, and left a
  `<defunct>` child behind that nothing would ever collect. The identity
  check remains where it matters, before the escalation to SIGKILL.
- A resource guard's restart (`max_memory_restart`, `max_cpu_percent`)
  counts like a crash. After the graceful stop (`kill_signal`, SIGKILL
  after `kill_timeout`) the app emits `restart`, sits in `waiting
  restart` for `restart_delay` / the exponential backoff, then
  respawns, `↺` +1 — it used to respawn at once, and since the exit
  always came past `min_uptime` an app over its limit from the start
  cycled forever. A trip within `min_uptime` + 10 s of the spawn
  (measured at the trip, not at the exit: the stop does not count) is
  unstable and counts toward `max_restarts`; a later one starts the
  count over. `max_restarts` unstable trips in a row end in `errored`:
  with the defaults, 16 trips for an app over its limit from the start,
  never for one that runs more than about 11 s between trips. A user
  `restart` / `stop` / `delete` during the guard's stop, a watch or
  cron restart start a new series (immediate, backoff reset), `3pm
  reset` clears the counts, and a guard stop or a pending restart
  crosses an in-place `3pm update`. `max_restarts = 0` ends the app
  `errored` on its first trip, stable or not, as on its first crash.
  `restart_policy` (`never`, pm2's `autorestart = false`) and
  `stop_exit_codes` do not stop a guard restart: they judge the exits an
  app makes on its own, and the exit after a trip is its answer to the
  guard's `kill_signal` — a recycle of the daemon's own, like a watch or
  cron restart, and like pm2's `max_memory_restart`.
  `threepm-core/src/policy/guards.rs`.
- A log file that cannot be opened (`out_file`, `error_file`,
  `log_file`: a directory that cannot be made, a permission) fails the
  spawn before the process starts, and `last error` names the file. It
  used to be opened after the spawn: the failure left the app running,
  unsupervised and never killed, and — under `cgroup_enforce` — its
  leaf never removed, one of each per retry until `errored`.
- `3pm ls` failed past about 700 apps: a client read the daemon's reply
  under the 1 MiB cap meant for what a peer sends the daemon, and a
  `list` carries every row (~1.5 KB each). Replies are now read up to
  32 MiB (`MAX_REPLY_BYTES`) by `threepm_protocol::client`, by the
  handoff's reconnection and by the Node client (`RpcConnection`);
  requests, control messages and the event stream keep 1 MiB.
  `CodecError::LineTooLong` names the cap it hit.
- `cgroup_enforce`: the child joins its leaf before it `exec`s — the
  daemon makes the leaf and writes `cpu.max` / `memory.max` first, the
  spawner's pre-exec hook writes the child into `cgroup.procs` — so
  nothing it forks starts outside; the pid used to be attached after
  the spawn. A join that fails fails the spawn (`cgroup_enforce: cannot
  join <leaf>/cgroup.procs: …`, a spawn failure like any other) and the
  leaf is removed. The leaf is `<name>-<id>-<instance>-<lineage>-g<gen>`,
  never shared by two incarnations nor with a daemon started cold after
  a crash (the lineage, `THREEPM_BOOT`, is drawn at every cold start
  and kept by a handoff); a leaf that already exists is refused. When
  the incarnation is over — exit, `delete`, death during a handoff —
  everything left in the leaf is killed (`cgroup.kill`, else SIGKILL
  member by member, nested cgroups included, the leaf frozen through
  `cgroup.freeze` for the pass so that no member's pid can be reused
  between the read and the kill, then thawed) before it is removed,
  members that left the process group included — the cgroups the app
  made below its leaf are removed first, deepest first, never through a
  symlink —, then the empty leaves of the instance's older incarnations
  go too. A leaf whose `cpu.max` / `memory.max` cannot be written is
  removed with the failed spawn. The empty leaves a crashed daemon left
  behind are not cleaned.

### Internal
- Fleet RPC, simplification pass, no behaviour change: the gate tests
  register their control connection and their `fleet_ready` through two
  shared helpers instead of three hand-written `SupMsg` literals, a
  local call refusing a promised blob is proved once rather than twice,
  and `agent_dir` is inlined into its only caller.
- The agent module drops `FleetHttp::get_blob`, which nothing has called
  since `--out` streams its answer (`get_blob_into`), and builds its
  settled `CallResult` fixtures from one helper.

### Added
- **Hosted users: one root daemon, sockets per account.** A root daemon
  now hosts the accounts listed in `[users] allow` of its `config.toml`
  (`dir`, `max_apps`; read at start, so `3pm update` applies a change).
  Each gets its own `rpc`/`pub`/`ctl` sockets (0600, chowned to it,
  peer-uid checked one account per listener) in a root-owned home under
  `/var/lib/3pm/users/<name>/` marked `hosted`: its CLI and the Node
  client find that home by themselves and never start a daemon there. A
  hosted connection is an identity — it sees, lists and targets only the
  rows it owns (anything else is `not_found`, never an existence oracle),
  and the daemon-level commands (`save`, `resurrect`, `kill`,
  `internals`, `hostmetrics`, `orphans`, the `update` handoff) answer the
  new `forbidden` error code. Two axes: files follow the new per-app
  `user` field (the account the app runs as: uid, gid, groups, a clean
  environment built from scratch, `PR_SET_NO_NEW_PRIVS`, no descriptor
  and nothing of root's environment inherited — `3pm start api.js --user
  srt` works for root too), visibility follows `owner`, which the daemon
  stamps from the connection on every entry path (`start`, `apply`, a
  patched `restart`, a rename, `scale`) and a client can never set.
  Logs, rotated generations and pid files of such an app are
  `root:<gid> 0640` in that home (0750, root-owned: the account reads,
  root writes), a hosted app may not choose its log paths, its `watch`
  roots stay inside its `cwd`, one account runs at most `max_apps` rows
  (default 32) over at most 64 live connections per socket, and app
  names stay one global namespace (root frees a squatted one with
  `3pm delete`). An account needs a private primary group; uid 0, a
  shared group and a duplicate uid are refused, each with a log line,
  never fatally. A daemon that is not root ignores `[users]`.
  `FEATURES.md` → *Hosted users*, `docs/protocol.md` → *Hosted users*.
- **`persist = false`** (`--persist false`, or in an ecosystem file): an
  app supervised and restarted like any other, carried across `3pm
  update`, but left out of `dump.json` — so out of `3pm save`, out of the
  implicit save of `3pm update`, and absent after a reboot. For the
  ephemeral backend that must not come back before the app that owns the
  hardware starts it. Toggling it never restarts the app — it is not part
  of the configuration `3pm apply` compares, only of what `save` writes —
  so a backend holding a camera keeps it. A spec saved by an earlier 3pm
  reads back as `true`, the historical behavior.
- **Self-hosted releases: `just publish`** builds the four tarballs on a
  Mac (Linux via `cargo-zigbuild`, glibc ≥ 2.28) and uploads them to
  `https://3pm.edgecr4ft.net` (`vX.Y.Z/` + `checksums.txt` +
  `version.json`, then `latest/VERSION`, `latest/version.json` — version,
  date, install command, every asset's url/size/sha256 —, `install.sh` and the landing page
  `distribution/site/index.html`). Each run ships a new minor version
  (bumped in `Cargo.toml` when the current one is already online, and
  stamped on the bundled modules so `3pm update` refreshes them);
  `--force` republishes the current version, `--dry-run` packages only. `install.sh` now
  defaults to that site, takes the version from `latest/VERSION`, and is
  also the updater: rerun, it hands a daemon started from the same
  install off to the new binary (`3pm update`, apps keep running).
- **SDKs on the download site: `just publish-sdk`** puts the three
  application-metrics SDKs on `https://3pm.edgecr4ft.net`: `@3pm/api` as
  an archive (`npm install
  https://3pm.edgecr4ft.net/sdk/node/3pm-api-0.1.1.tgz`), `threepm-io`
  in a pip index (`pip install --index-url
  https://3pm.edgecr4ft.net/simple/ threepm-io`), the C++ headers as an
  archive for CMake's `FetchContent`, and `sdk/sdk.json` with every
  version, file and sha256 (the landing page lists the SDKs from it).
  Each SDK ships when its own version is not online yet, after its tests
  and an install of the fresh archive into an empty project; older
  versions stay installable, and the archives are reproducible (a
  `--force` republish keeps their sha256).
- **Custom actions: `3pm trigger <app> <action> [params]`** — an app
  exposes functions on its control connection (`io.action(name, [opts],
  handler)` in `@3pm/api`); the daemon triggers one by a correlated call
  (`action` → `action_reply` on `ctl.sock`, `trigger` / `triggered` on
  `rpc.sock`), on every instance of a name (16 at most) or on one
  (`--instance`, `--src`), with the result inline (JSON ≤ 512 KiB) or as
  a file under `~/.3pm/actions/<app>[-<n>]/` (≤ 64 MiB, 20 per instance
  and 200 MB kept; a file is never pruned while its call gathers and is
  spared by the count for 10 minutes, what a timed-out call leaves
  behind is collected, a symlink or the `tmp` extension is refused).
  Bounded: the caller's `--timeout` under the declaration's (30 s
  default, 10 min ceiling), one call per (source, action), four per
  source, 32 per daemon, and never the last slot of an app's control
  queue, kept for `shutdown` (`busy`); typed per-instance errors
  (`failed`, `timeout`, `busy`, `gone`, `refused`, `too_large`);
  `remote: false` keeps an action off the fleet backend. `3pm trigger
  <app>` and `3pm show` list what each source declares (`--src` keeps
  one; an unknown source exits 2 and names the known ones); every call
  is a `proc_action` event on the bus (`3pm events -f proc_action`),
  without its params or result — a trigger refused as a whole leaves one
  per instance it named. Timeouts show to the millisecond (`1.5s`).
- **`3pm profile <app> [--heap] [--duration 10s] [--src S]`** — the SDK's
  built-in `3pm:profile` action (declared by `io.init()` on the `main`
  source — `profile: true` adds it on any other, `profile: false`
  withholds it — and counted toward the 32 actions per source;
  `--duration` from 1s to 60s, a usage error outside):
  a CPU or heap sampling profile through
  `node:inspector`, left as a `.cpuprofile` / `.heapprofile` file (opens
  in Chrome DevTools and speedscope) and summarised — the hot frames by
  self time or bytes, and for a CPU profile how long the process was
  busy, its idle time apart (`idle` in the JSON)
  (`threepm_core::profile_summary`, shared with the fleet backend). Its
  params are checked, never coerced (`kind` `cpu` or
  `heap`, `duration_ms` 1 000 to 60 000), and a call whose timeout cannot
  hold the duration plus 15 s to write the file is refused before
  sampling. In an Electron app the main process's `io.ipcBridge`
  profiles each renderer over `webContents.debugger`: a debugger already
  attached (DevTools) is an immediate `debugger already attached`, each
  CDP command gets at most 10 s, cut shorter so setup, sampling and 5 s
  to write the file fit the call's timeout, the relay detaches only what
  it attached, and a source shared by several windows is refused (give
  each window its own). A renderer's own `io.action` calls are relayed
  over IPC (`3pm:actions`, `3pm:actions_ack`, `3pm:action`,
  `3pm:action_reply`): each window keeps its own actions, dropped when it
  commits a navigation (a reload too), crashes or closes — a navigation
  that never commits keeps them, and the new page is asked to declare
  its own. A declaration is acknowledged and resent until it is, so one
  made before `io.ipcBridge()` still arrives; a renderer's `shutdown()`
  withdraws it; renderers cannot take `main`, `pid-*` or `worker-*`; and
  only the window a call went to can answer it.
- **`@3pm/api` names every connection**: `io.init()` without a `source`
  says hello as `main` in the process 3pm spawned (its series carry
  `src="main"` from now on), `worker-<pid>-<threadId>` in a worker
  thread (thread ids restart in every process) and `pid-<pid>` in a
  forked child or cluster worker (told apart through `THREEPM_IO_MAIN`),
  so they never evict each other. Two processes that both claim `main`
  (`sh -c 'node a & node b'`) settle it: the refused one falls back to
  `pid-<pid>`, and that collision never counts toward the SDK giving up
  for good. A child started by a non-Node parent otherwise cannot be told
  apart — name it. An invalid source
  falls back to that default. Action replies and the declaration have
  their own budget on the socket (1 MiB a line), so metrics cannot crowd
  them out: a reply that cannot be written fails the call instead of
  timing it out, and the declaration goes right after `hello`, resent
  until written. `io.action('3pm:…')` throws (the prefix is the SDK's),
  `ctx.commitFile` past the call's deadline deletes the `.tmp` and
  throws, and a lone surrogate in a result becomes U+FFFD. Whatever a
  handler throws answers `ok: false` (no unhandled rejection, no stuck
  "already running"), and a result over 512 KiB or nested deeper than 64
  levels fails `too_large` instead of timing out.
- **`3pm link` runs the fleet backend's commands** — the agent answered
  `unavailable` to every command until now. Each one runs in its own
  task: checked against the capabilities the backend accepted, narrowed
  to those the node offered and its certificate grants (`forbidden`),
  four at a time (`ping` apart; `unavailable` beyond), bounded by the
  call's deadline — counted from receipt, less a second for the transit,
  so the agent stops before the backend gives up (nothing is sent past
  it) — and tied to its connection
  (a call cut by a drop is abandoned, never answered on the next link).
  `restart`, `stop` and the new `action` go to the daemon; an action is
  always triggered `remote: true`, so one declared `remote: false`
  answers `refused`, and only `remote` actions are published with the
  sources. A remote `restart` or `stop` never reaches a module app (the
  agent itself included): it is refused `forbidden`. New capability
  `proc:action`, part of the default set; a `link.toml` written before
  this release (`version = 1`) holding exactly the earlier three is read
  as all four, one written now (`version = 2`) means exactly what it
  lists. A result travels gzip + base64: inline while the whole reply
  stays under 256 KiB compressed, else as `blob_chunk` frames (512 KiB
  each, four in flight) sent ahead of the reply; a result file must be a
  regular file under `~/.3pm/actions/` (a symlink is refused), read,
  gzipped and hashed in one pass, at most 32 MiB − 64 KiB over the link
  (refused before reading; 64 MiB stays the local cap for `3pm
  trigger`), and every gzip byte of a reply stays within 64 MiB so the
  backend never drops a blob. An outcome's data and file are weighed
  before anything is sent, and an error never names local paths.
- **More Node built-in metrics in `@3pm/api`** (`builtins: true`):
  `nodejs.eventloop.utilization`, `nodejs.eventloop.delay.p99` / `.max`
  (native `monitorEventLoopDelay`), `nodejs.gc.duration{kind}` (every GC
  pause), `nodejs.heap.committed` / `.limit`, `nodejs.memory.external` /
  `.array_buffers`, counters `process.page_faults.major` and
  `process.context_switches.involuntary`; `builtins: { heap_spaces: true }`
  adds `nodejs.heap.space.used{space}`. The four original metrics are
  unchanged.
- **`io.electron(require('electron'))`**, Electron main process: Chromium
  processes by type (`electron.processes`, `electron.process.cpu`,
  `electron.process.memory`), power state (`electron.power.on_battery`,
  `.thermal_state`, `.speed_limit`), counters `electron.process.gone{type,reason}`
  and `electron.unresponsive`, and events for crashed processes, hung
  windows, sleep/wake and power changes.
- **Renderer built-ins**: `createRendererIo(ipcRenderer, { builtins: true })`
  reports `renderer.eventloop.delay`, `renderer.longtask.duration`,
  `renderer.heap.*` (Electron's `process.getHeapStatistics()`, else
  `performance.memory`), `renderer.blink.*`, `renderer.cpu.utilization`
  and `renderer.dom.nodes`, still without any Node builtin module.
- **`3pm start "npm run dev"`**: a quoted command line (a target with a
  blank that is not a file) runs under `bash -c`, else `sh -c` (pm2), on
  the CLI, in ecosystem files and in `@3pm/api`, named after its words
  (`npm-run-dev`, `python-src-app.py-port-3000`; `--name` overrides).
  Separate args, an interpreter or its flags beside it are errors (pm2
  drops them).
- **`--interpreter none`** runs the script itself (pm2); on `restart` it
  clears the app's interpreter.
- **`--interpreter-args` / `--node-args`**, `interpreter_args` in files
  (pm2's `node_args`, `interpreterArgs`; string or list): the
  interpreter's own flags, before the script. `3pm show` row
  `interpreter args`; additive `StartSpec.interpreter_args` on the wire,
  never sent over the fleet link.
- **pm2 spellings** `exec` / `cmd` / `command` (`script`) and
  `exec_interpreter` (`interpreter`) in files and `@3pm/api`; two
  spellings of one field are an error.
- **YAML and JavaScript configs**: `.yaml`/`.yml` and pm2's
  `*.config.js|cjs|mjs` (evaluated by the CLI's `node`, in the shell's cwd
  and environment, 10 s / 16 MiB at most), everywhere a config file is
  taken; JSON, YAML and JS also take pm2's bare list of apps and single
  app shapes.
- **`3pm start -`** / **`3pm validate -`**: the config piped on stdin
  (JSON or TOML); `apply -` is refused.
- **`3pm start`**, **`3pm validate`**, **`3pm apply`** without a target use
  the current directory's `ecosystem.{toml,json,yaml,yml,config.js,config.cjs,config.mjs}`,
  first found wins.
- **`3pm start npm -- run dev`**: a start target that is a bare command
  name and not a file in the cwd is looked up in your PATH (pm2), on the
  CLI, in ecosystem files (`script = "npm"`) and in `@3pm/api`
  (`{script: 'npm'}`). The cwd still wins; a target with a `/` never
  searches PATH. The app runs the PATH entry itself (`…/bin/npm`, which
  survives an upgrade) under the interpreter inferred from the file it
  links to (`npm-cli.js` → node, resolved against your PATH), and is
  named after it (`npm`). A miss says `looked at <cwd>/npm, then PATH`.
- **`3pm ls --tree`** (`-t`): each app's process tree under its row,
  compacted — a chain of single children is one line (`npm › bash ›
  node › Electron`), twins are grouped (`Electron Helper ×4`), `⚠` marks
  a process that left the app's process group and that `3pm stop` will
  not reach — `--all` unfolds it, one row per process; with `--watch`
  the period defaults to 1 s (the daemon's tick). `3pm ls` itself
  writes `saw +13` when an app has other processes. `3pm show` says
  `cpu 62.2% (14 processes)`, `mem 3.0gb (14 processes, RSS sum)` and
  lists every process under ` Processes` (`⚠ detached` on the
  escapees). `--json`: `proc_count` and `tree_rev` on every record;
  with `--tree`, and in `show`, a `tree` object per app (absent when
  the app runs alone or is stopped). An older daemon renders both
  without trees and says `run \`3pm update\``. `packages/node`:
  `proc_count?` / `tree_rev?` on `ProcessRecord`, `limit_scope` among
  the `start` options. Wire, all additive,
  no protocol bump: `process_trees {ids, gen?}` → `{trees: AppTree[]}`
  (an unknown id, or one at another `gen`, left out; 256 lines per
  tree, 2 000 per reply shared between the trees that list members,
  `omitted` for the rest, the totals of the same tick as the lines;
  names cleaned of controls and bidi marks, private-use glyphs kept),
  `ProcessRecord.proc_count` / `tree_rev` (moves when the listed
  membership changes — a member enters or leaves the first 256 lines,
  detaches, takes another name (read again every tick: `comm` on
  Linux, which follows an `exec` and `prctl(PR_SET_NAME)` — Node's
  `process.title` —, the executable's file name on macOS, which follows
  an `exec` only), the
  incarnation exits — to the next value of one counter per daemon,
  which an in-place `3pm update` carries over and a daemon started
  cold starts again from 0), `DaemonInternals.metrics_tick_ms`;
  `limit_scope` on the
  spec (`tree` | `leader`), through the `SpecPatch` chain.
- **Process trees on the fleet link.** The agent (`3pm link`) sends the
  backend each app's process tree — the same lines `3pm ls --tree`
  draws — on the model of the application metrics, never inside the
  1 Hz frame: the shape whenever an instance's `(gen, tree_rev)` is
  newer than the one last written — and every instance once at the
  start of a session and when the daemon image changes, an app alone
  as an empty tree —, the figures every `welcome.trees_every_s` (1 s
  by default) for every app with more than one process, packed under
  the message cap. The telemetry's record gains `proc_count`, the fleet
  table's `+N`. Wire (`threepm-link`, additive): `trees {ts, trees:
  [RedactedTree]}`, `trees_request {ids}` (backend → agent),
  `welcome.trees_every_s` (absent = a backend predating trees: nothing
  is sent), `RedactedRecord.proc_count`; `budget::fit_app_metrics` and
  `fit_trees` share one packer; member names are cleaned again by the
  link and by the backend. A daemon predating trees
  (`unknown_request`) silences the task for a minute, then it probes
  again. `docs/backend-agent/link-protocol.md` §6c, `FEATURES.md` →
  *Process trees on the link*.
- **`3pm restart` takes every `start` flag** (pm2's attribute update):
  what you type is folded into the app's stored configuration, which
  then restarts on the result — `3pm restart api --name web -i 4
  --max-memory-restart 300M`. `--name` renames the app **in place**
  (same ids, same instance numbers, restart counters kept, pid files
  moved along); `-i N` reconciles the instance count like `start` does;
  `--env K=V` merges into the environment instead of replacing it. The
  fold is per app — a spec belongs to a name that owns N instances — so
  a patch through one instance covers the whole app. Renaming is
  refused when the target matched several apps, when the new name is
  taken, or when a module owns the app (it is named by its manifest);
  an invalid patch restarts nothing. `--watch` alone still
  toggles watch mode (pm2); `--watch=PATH` sets the paths.
  `--update-env` keeps re-reading the ecosystem file and cannot be
  combined with flags. Wire: `restart` carries an optional
  `patch: SpecPatch`, additive, no protocol bump.
- **3pm's own metrics in `3pm hostmetrics`** (`3pmDaemon` in `--json`):
  two tables after the sensors — `3pm processes` (the daemon and the
  host agent: pid, uptime, cpu, RSS, open descriptors, threads) and
  `3pm runtime` (apps by status, restarts, timers, bus subscribers, log
  sinks/rotations/bytes, `RLIMIT_NOFILE`, handoffs, agent respawns,
  `boot_id`). Measured by the host agent on the medium period, from the
  outside: the process rows from the OS, the counters pulled over
  `rpc.sock` (`Request::Internals`, one connection per tick, 2s bound —
  a daemon mid-update blanks the counters for one tick, never stalls
  the tier). Optional on the wire, never gates the first snapshot; `3pm
  internals` is unchanged. No protocol bump.
- `pong` and `3pm internals` carry a `boot_id`: a random id of the
  daemon *image*, redrawn at every boot and at every in-place handoff
  (the coming `3pm update` without app restarts), so a client can tell
  the daemon it was talking to from its successor at the same pid.
  `internals` also counts `handoffs`. Additive (`0` from an older
  daemon); no protocol bump.
- Two additive error codes for the coming handoff: `handoff_in_progress`
  (mutations refused for a moment, reads pass) and `handoff_refused`.
- **`3pm update` no longer restarts the apps.** The daemon hands itself
  off to the new binary in place: same pid, same children, same sockets
  — the apps keep running, their log lines are all kept (down to the
  byte read but not yet written at the switch), an exit during the
  switch keeps its real code, timers resume where they were. The new
  binary is checked first (`__daemon --handoff-check`): a binary that
  cannot take over is refused and `update` falls back to the previous
  cold restart (`--no-fallback` exits 3 with the apps untouched; `--cold`
  asks for the old behaviour). A failed exec resumes the old image with
  nothing lost; a new image that cannot adopt stops the apps cleanly and
  restarts them from the saved list; a new image that dies is caught by
  a guardian process that restarts the daemon on the same sockets. The
  report says how it went (`kept running` / `restarted`; `--json`:
  `mode` `handoff`/`cold`/`emergency`/`recovered`, `kept`, `restarted`,
  `duration_ms`, `fallback_reason`); `3pm internals` shows the boot id,
  the handoff count and the last outcome. Commands issued during the
  switch wait it out (a message says so); `3pm logs`/`events`/`runtime`
  followers stay attached. On the wire, all additive: a `handoff`
  request, a `daemon_handoff` event and `internals.last_handoff`.
- `3pm update` refreshes the modules after the daemon, in place: an
  installed module whose bundled version changed is reinstalled and its
  rows restarted (same rows, sites included; manifest apps added or
  removed by the release are started or deleted), and an app running
  from the bundle without an install — the agent after `3pm link` — is
  restarted on the new binary and re-stamped. Rows already at the
  bundle's version are left alone (a second `update` touches nothing);
  the comparison is per row, not per module, so a module whose files
  were copied but whose rows were not restarted (a refresh that failed
  midway) is picked up by the next `update`; `--skip-modules` skips the
  step. One line per module in the report;
  `--json` adds `modules: [{name, from, to, source, restarted, started,
  deleted}]`.
- `3pm install <name>` on a module already installed now restarts its
  rows in place instead of deleting and recreating them: the sites
  served with `3pm serve` (and any app started from the module's files)
  are kept, with their row and their history.
- `3pm ls --watch`: pm2's live table, redrawn in place where the cursor
  is (the history above stays, no blink: the cursor climbs over the
  previous frame and repaints it line by line) every 300ms — `--interval
  <MS>`, 100ms floor — under a `Last refresh:` stamp; the same body as
  `ls` (link line, orphan notices, host line). The terminal is re-read
  at every tick, so the table follows a resize; a frame taller than the
  screen repaints from the top of the visible screen instead. Off a tty
  the frames simply follow each other (a blank line between them) and a
  reader that leaves ends the command with exit 0; `--json --watch`
  prints one JSON array per tick (NDJSON).
- Application-metrics SDKs, one surface in three languages
  (`docs/app-metrics.md`): `io` in `@3pm/api` for Node and Electron
  (`packages/node`: `gauge`/`counter`/`meter`/`histogram`/`metric`/
  `event`/`notifyError`, opt-in built-ins — event-loop lag histogram,
  heap, handles, CPU —, `io.ipcBridge(ipcMain)` + `@3pm/api/renderer`
  for renderers), `threepm-io` for Python (`packages/python`, standard
  library only, daemon flush thread) and `threepm_io.hpp` for C++17
  (`packages/cpp`, header-only, wait-free `inc`/`set`/`observe` for
  real-time threads). Same behavior everywhere: disabled outside 3pm,
  never blocking, never throwing, whole registry every second,
  reconnection 1 s → 30 s, give-up after five refused `hello`s, events
  queued while connecting, labels bounded locally (3 keys, 50 sets then
  `overflow="true"`), histograms capped at 128 non-empty buckets by
  lowering their scale (OpenTelemetry style; C++ by a fixed 128-slot
  window), every cap counted in UTF-8 bytes like the daemon's, lines split
  under 32 KiB, histogram buckets identical bit for bit to the daemon's
  grid — `packages/exphist-vectors.json`, generated by the Rust tests and
  replayed by every suite. `just sdks` runs the three suites;
  `crates/threepm-cli/tests/e2e_io.rs` runs the three under a real
  daemon.
- The daemon accepts the label set `overflow="true"` from an SDK that
  folds its own excess label sets (the one reserved label an app may
  send); it merges with the daemon's own fold, under the same name cap
  and creation budget as any series.

- Application metrics and events on the fleet link, every second and
  differentially: the agent asks the daemon what changed since the
  last patch it wrote on the socket (`app_metrics {id, since_rev,
  gen}`) and relays it as `app_metrics {ts, procs: [AppMetricsPatch]}`
  (short keys, the daemon's `sid`s untouched; everything at the start
  of a session, on the backend's `resync {ids}`, and every 300 s per
  instance); `emit {app}` carries `io.event` / `notifyError` as the
  daemon admitted them. `welcome.metrics_every_s` sets the period
  (1 s floor). `threepm-link` gains the patch types, the pure reader
  `patch::apply` (a `Gap` on a patch off its base or incarnation) and
  `budget::fit_app_metrics`; `RedactedRecord` gains `app_series`,
  `app_metrics_rev` and `gen`. Fixtures `link/v1` extended (additive).
- `ProcessRecord.gen`: the incarnation of the instance (bumped at every
  spawn), so a reader tells a restart from a revision that happens to
  land on the same number.
- `app_metrics` request: `gen` names the incarnation `since_rev`
  belongs to — another one gets a full answer. `hist[].reset` in the
  answer says the bucket table was cleared after `since_rev` (scale
  change, count going backwards), so a differential reader replaces
  its buckets instead of merging.

### Fixed
- The `overflow="true"` fold could sum two huge counters (or histogram
  sums) to `inf`, which serializes as `null` and made the instance's
  `app_metrics` answer undecodable (`3pm show` failed, the agent dropped
  its daemon connection every second). A non-finite fold is now ignored.

### Changed
- A metrics batch that defines or changes nothing is no longer a new
  revision: an SDK flushing an idle registry every second leaves
  `app_metrics_rev` alone, so a differential reader has nothing to ask.
- Control channel: after `hello`, a line that is not JSON is skipped
  (debug-logged) instead of ending the connection — one bad line from an
  SDK no longer costs a source its series, nor makes the SDK give up
  after five reconnections. An over-long line or an I/O error still
  ends it.
- A histogram entry may carry up to 128 non-empty buckets (was 64), so
  a scale-1 histogram spans 64 doublings before an SDK has to lower its
  scale.
- Application metrics and events (pm2's custom metrics, for any language
  and any number of processes per app): a process of the app connects to
  `$THREEPM_CTL_SOCK`, says `hello` with a `source`, then sends
  `metrics` batches (gauges, cumulative counters, base-2 exponential
  histograms; up to three labels, a unit, a description) and `event`
  lines (OpenTelemetry log-record shape with an optional exception and a
  fingerprint). `3pm show` gains a "Custom metrics" section (approximate
  quantiles for histograms) and `show --json` an `app_metrics` object;
  `3pm events -f app` follows the events, with `×N` for the repeats the
  per-fingerprint rate limiter suppressed. New `app_metrics` request —
  full or differential by revision (`since_rev`), so a reader carries
  only what changed — and `app` bus event; `ProcessRecord` carries only
  counts (`app_series`, `app_metrics_rev`, `app_metrics_rejected`,
  `app_events_dropped`). One control connection per process of the app
  (a source that reconnects replaces itself; `shutdown` is written to
  all of them), peer pid recorded, every line validated on the
  connection task. Bounds: 1 000 live series per instance, 200 names,
  50 label sets then `overflow="true"`, creation budgets against churn,
  120 s TTL, 10 events/s. `docs/metrics.md`, "Application metrics".
- `3pm startup --level user` / `3pm unstartup --level user`: the boot
  service in your own session (a LaunchAgent, `systemctl --user`),
  without root — what a daemon that must share the GUI session (macOS
  AudioUnits) needs.

### Changed
- Crash restarts are immediate by default, as with pm2: a crashing app
  respawns at once and a crash loop reaches `errored` (red in `3pm ls`)
  after 16 quick exits in a moment, instead of sitting in
  `waiting restart` for minutes while the former always-on exponential
  backoff climbed to 15 s. Backoff is now opt-in with pm2's name:
  `exp_backoff_restart_delay = 100` in the ecosystem file or
  `3pm start --exp-backoff-restart-delay 100` (the 0.1 spelling
  `backoff_initial` / `--backoff-initial` is still accepted);
  `restart_delay` / `--restart-delay` is a fixed pause before every crash
  restart. `backoff_factor`, `backoff_max` and `backoff_reset_after`
  only apply once backoff is on. Wire shape unchanged
  (`backoff_initial_ms`, now `0` by default).

### Internal
- The two bundled modules shared nothing but a copy-paste: the child
  side of the control channel (`hello`/`ready`, the `shutdown` watch),
  the `--json`/`THREEPM_JSON` resolution, the `__run` role split, the
  `[3PM][ERROR]` exit mapping and raw `println!` calls. They now build on
  `threepm_protocol::module` (`is_run_role`, `Out`, `exit_code`,
  `ctl::{Identity, announce_ready}`) and `threepm_protocol::out` (the
  CLI's pipe-safe `oprintln!` family, moved there with the `[3PM]`
  chrome line and the color decision). Visible consequences: `3pm serve
  … | head` no longer panics on the closed pipe, a module's `[3PM]`
  prefix is colored like the CLI's, and under `--json` its notices go
  to stderr instead of being dropped. `clippy::print_stdout`/
  `print_stderr` are denied in the modules as in the CLI; the daemon
  and the modules name `THREEPM_CTL_SOCK`/`THREEPM_ID`/`THREEPM_GEN`
  through one set of constants; `ControlOut::decode_lenient` mirrors
  `ControlIn`'s.

### Added
- `3pm ls` warns about the processes a daemon that died uncleanly
  (`kill -9`) left running: they were invisible — a fresh daemon manages
  nothing — while still holding their ports. Below the table: `1 process
  from a previous daemon still running unmanaged: api (pid 4321)` and the
  way out (`3pm resurrect`, or `3pm start <name>`). Read-only: nothing is
  killed by `ls`. Backed by a new `orphans` request (a scan of `pids/`
  off the actor thread, re-filtered against the managed rows at reply
  time); `ls --json` is unchanged and an older daemon prints nothing.
- The fleet link above the process table, pm2's `⇆ PM2+ activated | …`
  line: a host that is linked opens `3pm ls` with `⇆ 3pm link | node
  3pmn_… | dash: <bucket page>`, green when the link is established, red
  with the reason when it is not (`link down for 5s (transport error),
  retrying`, `parked: the node was revoked`, `app \`agent\` is
  stopped`…). A 3pm with no backend prints nothing, and `ls --json` is
  unchanged. The line costs no request: the CLI reads the agent's
  `link.toml` and `session.json` itself and finds the app's row in the
  list the table just printed. On a narrow terminal the address is
  dropped first, the reason last.
- The backend's page for a cluster is now printed wherever the link is:
  `3pm agent status` (and `3pm link`) gained a `dashboard` line, and
  `3pm link <cluster-key> <token> <server>` ends on `[3PM] Dashboard
  <url>` (`"dashboard"` in `--json`). It is derived from the recorded
  server — `ws://` → `http://`, `wss://` → `https://`, path
  `/b/<cluster-id>`.
- Target patterns: a target holding `*` or `?` selects processes by glob
  instead of naming one — `3pm logs 'victoria*'`, `3pm restart
  'worker-?'`, `3pm delete 'bench*'`. No flag and no mode; `*` is any run
  of characters, `?` exactly one, the match is whole-string and
  case-sensitive (`*victoria*` is the substring form), and it applies to
  the app name and the namespace, the same two fields a literal name
  matches. Available on `logs`, `stop`, `restart` (with `--update-env`
  too), `delete`, `reset`, `show`, `sendSignal`, `flush`, `rotate` and
  `events --process`; `scale` refuses one, an instance count belongs to a
  single app. A pattern that selects nothing is a notice when other
  targets matched and the run's error otherwise — for a mutation, raised
  before the first request, so nothing is left half-done. Patterns are
  resolved in the CLI against the daemon's list and only the selected
  names reach the wire (`events --process` excepted: a server-side
  filter, so the glob travels). Module rows are shielded from a pattern
  on a mutation exactly as they are from `all`, never from a read.
- `3pm link <cluster-key> <token> <server> [flags]` and `3pm unlink
  [flags]`: built-in sugar over the bundled `agent` module, so the line
  pasted from a backend's bucket page runs as printed. Every argument is
  handed to the agent untouched (`3pm link --help` is the agent's help,
  with its flags), the module owns the exit code, `--json` on either side
  of the verb reaches it through `THREEPM_JSON`. Without an `agent`
  module the command fails with a reinstall hint before any daemon is
  spawned; `link` / `unlink` are reserved from module names. `3pm link
  stop` pauses the link (the agent app is stopped and the list saved,
  the enrollment kept) and `3pm link start` resumes it on the existing
  enrollment, registering the app again if it was deleted; `3pm link
  status` is `3pm agent status`, `3pm link delete` (`rm`, `remove`) is
  `3pm unlink`. `stop` and `start` refuse a host that is not linked. `3pm link` alone is a check: the status plus a `link` line
  (`connected for …` or `NOT established — <why>`), exit 0 only when the
  link is established, 1 with the reason on stderr otherwise; `--json`
  adds `session {connected, for_ms, sessions, retries, parked,
  last_disconnect?, server_version?}`. The running agent keeps
  `<state dir>/session.json` up to date for it (pid-checked, removed on
  a clean exit).
- `3pm agent link|unlink|status` (`modules/agent`, the second bundled
  module, own workspace and lockfile): the fleet link to a `3pm-backend`.
  This step ships the skeleton — the CLI dispatched by 3pm, inputs
  resolved from the pasted line with `THREEPM_LINK_*` overriding it
  (clear text to a non-loopback host refused with `ERR_INSECURE_SERVER`
  unless `--insecure`; the token is never echoed), `THREEPM_AGENT_*`
  options for the supervised process, the state directory
  (`<home>/agent`: owner-only `node.key` written temp+fsync+rename,
  `node.crt`, `ca.pub`, `link.toml`), `status` (link + certificate expiry
  + the app's row) and `unlink` (delete, save, forget). `link` enrolls
  for real: Ed25519 node key, the challenge/proof dialogue over a
  bounded WebSocket (`ws://` clear text or `wss://` against the Mozilla
  roots; pinning comes later), the issued certificate verified against
  the cluster CA before anything is written, then the supervised `agent`
  app is started (`wait_ready`) and saved. Refusals exit 1 with nothing
  written; `--force` re-enrolls with a fresh key. The supervised app
  holds the link: handshake (challenge → proof + hello → welcome, 10 s,
  version range and clock skew checked), one writer (control frames
  never dropped, telemetry last-wins with a `dropped` count, ping 20 s,
  flush 5 s, full write buffer ⇒ dead) and one reader (calls acked at
  once, 60 s of silence ⇒ dead), reconnection on the plan's schedule
  (full jitter, class per close code / HTTP status, revoked ⇒ parked
  with an hourly probe), polite close on `stop` / signals, re-issued
  certificates verified and stored. Telemetry at `THREEPM_AGENT_INTERVAL_MS`
  (1 s): the redacted process table (`RedactedRecord`, never a
  `ProcessRecord`: env key names at most, no values, paths or log lines)
  and the host snapshot — identity stripped from the tick, verbatim
  `host_full` on the first frame, on an identity change and every
  `THREEPM_AGENT_FULL_EVERY_S` (the backend may ask for another period)
  — plus the daemon's version/protocol/pid; `process` and
  `daemon_shutdown` events forwarded as `emit`, the latter followed by a
  polite close. Commands, renewal and TLS pinning follow.
- `3pm-backend` ingests the link: `GET /api/v1/link` admits a node by
  its certificate (signature under the bucket CA, key and state read
  from the database on every handshake; expired-but-signed re-issued in
  `welcome.cert`), runs the challenge/proof/hello dialogue, then feeds
  the hub — presence `online` → `stale` (3 s) → `offline` (30 s or the
  socket gone, both tunable), the last snapshot per node, a 300-point
  `{t_s, cpu, rss_kb}` ring per process, a second session of the same
  node closing the first 4409. `GET /api/buckets/{id}/events` streams
  `init` / `node` / `telemetry` / `emit` with monotonic ids,
  `Last-Event-ID` replay and `reset`; `GET /api/buckets/{id}` carries
  the same `init` shape. `NodeView.state` is now the displayed state
  (`pending` / `revoked` / presence) with the database state in
  `enrollment`.
- `3pm-backend` serves its first dashboard: `/` lists and creates
  buckets, `/b/<id>` mints a token into the line to paste, lists the
  nodes with their state and, per node, the process table (status,
  pid, cpu, memory, restarts, uptime) refreshed by the SSE feed at
  1 Hz with the raw `telemetry` JSON one click away. Vanilla JS, no
  build step, assets embedded in release builds. `tests/real_agent.rs`
  runs the vertical for real — backend, a real `3pm`, `3pm agent link`,
  the feed — under `just e2e-modules`.
- `3pm-backend` node API: `GET /api/buckets/{id}/nodes`, `POST
  …/nodes/{node}/approve` and `…/revoke` (revoking closes the live
  session 4403 `ERR_NODE_REVOKED`; approving again re-admits the host
  with the key on record). `NodeView` gains `cert_expired` and
  `last_auth_error {code, at}` — the last time the link turned the node
  away while pending or revoked, announced on the feed — and the bucket
  page gets Approve / Revoke buttons per node.
- `stop`, `restart`, `delete`, `reset` and `logs` accept **several
  targets** (pm2 parity: `3pm delete 0 1 api`), mixing names, ids, `all`
  and ecosystem files. `all` absorbs the rest of the list and duplicates
  fold, so no row is acted on twice. An unresolvable target no longer
  costs the others: the batch acts on everything that resolves, notices
  each failure and exits with the shared wire code (still 2 for an
  all-NotFound run); `--json` keeps its one-document contract by emitting
  the error doc alone. `logs` treats an unmatched name as a notice and
  streams the rest — only an empty selection is an error.
- Modules (`pm2 install` parity, bundled with the install instead of
  downloaded): a module is a directory with `module.toml` (`[module]`
  name/version/description/`command`, optional `[[apps]]` in ecosystem
  syntax). `3pm install <name|dir>` copies it into `~/.3pm/modules/`,
  starts its apps (named `<module>:<app>` pm2-style, namespace = module,
  tagged with `spec.module`) and saves the process list; `3pm uninstall`
  deletes them (plus any app running from the module's files) and the
  directory; `3pm module list` / bare `3pm install` show installed and
  bundled versions. `3pm <module> …` runs a module's command (installed
  copy first, else the bundled one — no install needed for command-only
  modules); unknown subcommands get a did-you-mean over built-ins and
  modules. Module apps sit in a second **Modules** table in `3pm ls`,
  are skipped by `stop|restart|delete all`, `delete --flush all`,
  `apply --prune-all` and `restart --update-env`, and vanish from
  `resurrect` once their directory is gone. Bundle lookup:
  `THREEPM_MODULES_DIR`, `<3pm dir>/modules`, `<3pm dir>/../lib/3pm/modules`
  (tarball, install.sh, deb/rpm all ship `modules/`).
- `3pm serve [dir] [port]` (pm2 `serve`), the first bundled module
  (`modules/serve`, its own workspace and lockfile): `--spa`, `--ftp`
  directory listing (trailing-slash redirect, dirs first, dotfiles
  hidden), `--basic-auth-username/--basic-auth-password`, `--host`,
  `--homepage`, custom `404.html`, CORS, HEAD, 405 for other methods,
  path-traversal 403, malformed escapes 400 (GHSA-9prm-75wm-xpg6),
  symlinks resolving outside the served directory 403 (and hidden from
  `--ftp` listings — a departure from pm2, which follows them; enforced
  by the open itself, resolved from a descriptor of the served
  directory, so a link swapped after the check is still refused),
  streamed bodies; the app is `wait_ready` (online once bound), named
  `static-page-server-<port>`, tagged with the module (so it lists under
  **Modules** below the apps started by hand and `all` targets skip it),
  restarted in place when served again on the same name or port, an
  explicit busy port is reported up front while the default moves to
  the next free port, and `PM2_SERVE_*` env is honored for ecosystem
  files written for pm2. The command prints the `Local`/`Network` links
  (LAN address for a phone) and a QR code of the network link
  (`--no-qr`); it no longer prints the process table. Given a single
  `.html` file, it serves the file's directory with that page as the
  homepage, opens it in the browser when run from a terminal (links
  only, no QR code) and stays attached: Ctrl-C stops the server (`--detach` / `--attach` override
  the per-mode default). `3pm <module>` forwards Ctrl-C to the module
  and waits for its exit status.
- `3pm internals` ends with an `internal processes` table: every process
  3pm runs for itself (the daemon, the host metrics agent) with pid,
  status (`online` / `waiting restart`), uptime, cpu, RSS and respawns.
  `--json` carries it as `internal_processes: InternalProcess[]`
  (additive; the agent's cpu/mem are sampled on the daemon's metrics
  tick and attributed only to the incarnation they were taken for).
- `--attach` on `start`, `restart` and `stop` streams the affected apps'
  logs right after the action (pm2 parity: table, then live stream with
  no history, Ctrl-C to leave). Conflicts with `--json`.
- `3pm bench` starts a demo fleet for testing — 4 node apps (HTTP,
  CPU burner, memory ballooner, a 15 s crasher), 2 python apps (HTTP,
  JSON worker) and 2 bash loops — written to `<home>/bench/` (or
  `--dir`), all in the `bench` namespace; rerun restarts in place,
  `--delete`/`--clean` removes them, `--json` returns the `start`
  contract. Missing `node`/`python3` skip their apps with a warning.
- `3pm ls` (and every command that reprints the table) ends with pm2's
  one-line host summary — `host metrics | cpu: … | load: … | ram: … |
  gpu: … | en0: ⇓ … ⇑ … | disk: ⇓ … ⇑ … /data 85%` — read from the host
  agent's cache under a 500ms budget (absent, never an error, when the
  agent has no sample). Throughput comes from new agent-derived rates on
  the wire: `HostNic.rx_bytes_per_s/tx_bytes_per_s/errors_per_s`,
  `HostDisk.read_bytes_per_s/write_bytes_per_s`,
  `HostMetrics.rate_window_ms` (additive, optional). `ls --json` unchanged.
- Host metrics agent: `3pm hostmetrics` is now served from a separate
  process (`3pm __host-agent`) the daemon spawns, respawns with backoff
  and terminates — host collection can no longer take the supervisor
  down. The agent samples in cost tiers on independent tickers — cheap
  gauges every 1s (`THREEPM_HOSTMETRICS_INTERVAL_MS`), process/socket
  walks, sensors, GPU tools and `statvfs` every 10s
  (`THREEPM_HOSTMETRICS_MEDIUM_MS`), NTP and identity every 5min
  (`THREEPM_HOSTMETRICS_SLOW_MS`) — so a hung `nvidia-smi` or a stalled
  mount never delays CPU/RAM, and the fast collector keeps its CPU
  window across ticks (no 200ms warmup after the first sample). The
  `ls` throughput is a 1s rate. Wire: additive
  `HostMetrics.sampled_at_epoch_ms.{fast,medium,slow}`; the overview
  adds `sampled (medium)`/`(slow)` rows when a tier lags. The agent
  answers from its cache in milliseconds; the overview shows a
  `sampled … (Ns ago)` row, `3pm internals` a `host agent` row (pid,
  restarts). An unreachable agent is a clean `err{unavailable}` naming
  `hostagent.log`. Home gains `host.sock`, `hostagent.{lock,pid,log}`.
  Wire: additive (`HostMetrics.collected_at_epoch_ms`,
  `DaemonInternals.host_agent_pid/host_agent_restarts`,
  `ErrCode::Unavailable`); `client::connect_at`/`ReadyPipe` for embedders.
- `@3pm/api` (`packages/node`): Node.js client with pm2's programmatic
  shape (`connect`/`start`/`list`/`stop`/`restart`/`delete`/`describe`/
  `scale`/`save`/`launchBus`, promise-based, zero dependencies) over
  `rpc.sock`/`pub.sock`, daemon auto-spawn, `EventFilter` handshake,
  protocol-version check, and the frozen wire fixtures in its tests.
- `3pm runtime <file|script>`: container entrypoint (PID 1). Runs the
  daemon in the foreground as a child, streams app output to
  stdout/stderr (`--raw`, `--format`, `--timestamp`, `--json`), drains
  on SIGTERM/SIGINT, reaps orphans, and exits `2` when nothing is left
  to supervise (zero apps, or every app stopped/errored; `--no-auto-exit`
  keeps it up), `0` after a drain, `1` if the daemon died. Log files are
  off unless `--log-files`/explicit paths; `--delay SECS`. Every `start`
  flag applies. Wire/daemon: `SpawnMode::Foreground`, `__daemon
  --foreground`.
- `3pm events`: follow the daemon event bus, one line per event, with
  repeatable `-f GLOB` event filters (`process:*`, `log_err`, …),
  `-p NAME` app scoping, `--raw`, `--timestamp FMT` and `--json`.
- Server-side bus filtering: a `pub.sock` subscriber may send one
  `EventFilter` line (globs, app name, ids) and the daemon drops the
  rest before serialising it. `3pm logs` uses it; filter-less clients
  keep receiving everything. Wire: additive (`event_filter` fixture).
- `daemon_shutdown` event (`reason`: `kill`, `signal`, `home_removed`)
  broadcast once before any child is signalled and drained to every
  subscriber before the socket closes. `3pm logs` / `3pm events`
  followers now exit 0 with the reason on a deliberate stop, and fail
  with "daemon connection closed unexpectedly" only on a real death.
- Ecosystem values expand `~` and `$VAR` / `${VAR}` (`$$` escapes); an
  unset variable is an error, not an empty string.
- `3pm stop|restart|delete <ecosystem file>` acts on every managed app
  the file declares.
- Resource limits beyond memory: `max_cpu_percent` (soft CPU ceiling,
  same observe-and-restart guard as `max_memory_restart`),
  `cgroup_enforce` (Linux only: per-incarnation cgroup v2 leaf under
  `/sys/fs/cgroup/3pm` with hard `cpu.max` / `memory.max`; unsupported
  hosts fail the spawn before it happens) and `deny_gpu` (CUDA/ROCm
  visibility env vars). CLI: `--max-cpu-percent`, `--cgroup-enforce`,
  `--deny-gpu`. Wire: three additive `StartSpec` fields.

### Fixed
- `watch` on a single file fires. A root that is a file — a binary a
  build rewrites, `watch = ["target/debug/app"]` — is watched through
  its parent directory and only its own events count: the filter used
  to drop them as "the root itself" (a directory's own mtime event,
  which is noise), so the app never restarted; and on Linux a watch on
  the file itself was lost the moment a build replaced it (new inode).
  Its siblings in that directory — `app.d`, `deps/` — do not restart it.
- Fleet link, after the adversarial review of phases A–C. Backend: the
  60 s silence close really fires (one deadline reset only by agent
  frames; `--handshake-timeout-ms`, `--ping-every-ms`, `--silence-ms`);
  a refused link is drained before the socket drops, so the agent sees
  its close code instead of an RST; the hub view is read fresh after
  `welcome` and the node state re-checked after registering (a revoke
  racing the handshake now cuts the session; a re-issued certificate
  shows its serial); graceful shutdown cannot hang on a feed opened
  during it; enrollment has its own nonce store, releases nonces on
  every exit and budgets pending handshakes per client address
  (`--enroll-pending-per-ip`, 4429) so a tokenless flood cannot starve
  the links; enrollment persists use + node + serial + certificate in
  one transaction (no burnt use on a failure); WebSocket messages and
  frames are capped at 1 MiB before inflation; hyper's header-read
  timeout is armed (`--header-timeout-ms`); asset paths with `..` are
  refused before the embed store; mutating routes refuse cross-site
  requests (403 `ERR_FORBIDDEN`) and, with `--admin-token`, require a
  bearer (401 `ERR_UNAUTHORIZED`; the dashboard asks once). Link crate:
  `RedactedRecord.status` and `AgentEvent::Process.kind` are link-local
  enums with `Unknown` (a newer peer's variant no longer breaks the
  frame); a telemetry frame over 1 MiB is trimmed (`env_keys`, then the
  tail of `procs`, `procs_dropped`) instead of tearing the link down
  forever; a bare 4403 is Auth, not Stop; `version_overlap`. Agent: the
  handshake skips unknown and early `cert` frames; shutdown is honoured
  while connecting and shaking hands (no SIGKILL by the daemon when the
  backend is unreachable) and never sleeps a backoff afterwards; the
  event-bus forwarder backs off after an EOF; the identity ping is
  bounded; enrollment checks the server clock (`ERR_CLOCK_SKEW`). Plus
  the tests the review found missing on every side.
- `3pm ls | head` (any command whose reader closes the pipe early) died
  with a Rust panic — "failed printing to stdout: Broken pipe" on stderr,
  exit 101 — because `println!` panics on a write error. Every CLI write
  now goes through the output boundary (`oprint!`/`oprintln!`/
  `oeprintln!`, and the `__man`/`completions` generators render into a
  buffer instead of the raw fd): a vanished reader ends the command
  silently with exit 0, streamers (`3pm logs`, `3pm events`) included.
- `3pm <module> … --json` on a first run (daemon not yet up) printed the
  "Spawning 3pm daemon" notice on stdout ahead of the module's JSON
  document; the module's `--json` is now honored by the dispatcher
  (notice to stderr, `THREEPM_JSON` set), so stdout stays one document.
- An init-managed daemon (`3pm startup` units, `3pm __daemon --boot` by
  hand) released its own singleton lock at startup: readiness was
  signalled on fd 3 unconditionally, and without the CLI's pipe that
  descriptor is the daemon's `daemon.lock` — the byte went into the lock
  file, closing it dropped the `flock`, a second daemon on the same home
  could come up and steal the sockets, and the dangling lock `File`
  closed whatever had recycled fd 3 (an app log) on exit. The launcher
  now announces the pipe as `THREEPM_READY_FD`; unannounced, no
  descriptor is touched. Restart a daemon started by an older build to
  regain the lock.
- Config workflow (post-audit): `apply --only … --prune` no longer
  deletes apps that are still in the file but outside the subset
  (`--prune-all` + `--only` is refused); `instances = 0` no longer
  makes `apply` report a change on every run, and an instances-only
  change is a `scale` step (survivors keep their pid); `--only` naming
  a broken app reports that app's error instead of "no app named", and
  a broken app outside the subset no longer blocks the selected ones;
  duplicate app names are refused by `start`/`apply` (not only
  `validate`); env values may be bare numbers/booleans; a malformed
  `env_<n>` table or `[defaults]` misuse is reported per app instead of
  failing the file or passing silently; `validate` runs without a
  socket-capable `THREEPM_HOME`; `restart --update-env` prints loader
  warnings and, under `all`, skips CLI-started apps with a notice;
  `start <file>` / `apply` warn when a remembered profile is dropped;
  unknown-field notices no longer claim the field was "ignored" where
  `validate` rejects it.
- Daemon shutdown (`3pm kill`, SIGTERM) is bounded by a 30 s drain
  deadline; an unkillable child no longer hangs the daemon forever.
- A panic in a per-child task is reported as the incarnation's death
  (synthetic `ChildExited`) instead of leaving the row `online` with a
  stop that never resolves.
- Specs decoded from the wire or `dump.json` are validated
  (`StartSpec::validate`) before becoming rows: an invalid `Start` is
  refused with `InvalidSpec`; an invalid dump entry is skipped with a
  warning while the rest resurrects.
- Lifecycle timers (restart backoff, kill escalation, ready wait,
  dependency polls) are cancelled when their incarnation is superseded
  instead of lingering until they fire into the gen guard.

### Changed
- `modules/agent` is a git submodule of its own repository
  (`3pm-agent`, next to this one). Clone with `--recurse-submodules`;
  the `modules` and `msrv` CI jobs and the release build check it out.
  Nothing changes for users: the agent is bundled as before.
- The fleet backend left this repository: `3pm-backend` is now its own
  repository, checked out next to this one and path-depending on
  `../3pm/crates/…`. `just backend` / `just backend-dev` and the CI
  `backend` job are gone from here; the real-agent test runs there with
  `just e2e`.
- `3pm internals` reports `armed timers` (wire: `timers_armed`, additive).
- The `max_memory_restart` comparison and the "only online rows restart
  on a watch/cron tick" rule moved from the daemon into the pure policy
  (`MemorySampled{gen, rss_bytes}`, `TriggeredRestart`).
- The supervisor actor no longer does disk I/O inline: `save` (two
  fsyncs), the `resurrect` dump read and orphan probe, the per-spawn pid
  file write and the `internals` fd/load/file gauges run on blocking
  threads. A slow or hung `THREEPM_HOME` can no longer stall every
  process's timers and signals. The pid file lands a few ms after the
  process is reported online.
- The `depends_on` wait budget (`depends_timeout_ms`) is decided by the
  pure policy (`dep_wait_since` on the state, `Action::ForceSpawn`),
  not by the daemon; the model-based property test now covers it.

### Internal
- `Launching` sub-states are a policy-internal `Launch` enum
  (`SpawnInFlight` / `ReadyWait` / `ParkedOnDeps`) instead of
  `waiting_deps` + `pid.is_some()` probes; `Status` stays the wire type.
- Policy transitions share `begin_stop` / `park` / `reset_series`
  helpers; `delete` is a real transition instead of a filtered `stop`.
- Unit tests for `bus`, `ctl_server`, `metrics` and `bootstrap`.
- E2E: every fixed settle sleep replaced by a conditioned wait
  (`show --json`-backed `wait_for_*` and `expect_no_change_for`), port
  tests let the app choose its port, and the CI `retries` on the e2e
  group were dropped.

### Added
- Wire compatibility is now decided by `PROTOCOL_VERSION` (carried as
  `protocol` in `pong`) instead of the crate version: CLI and daemon of
  different builds interoperate as long as the protocol generation
  matches; a daemon predating the field is reported as skewed with the
  `3pm update` hint. Frozen JSON fixtures for every message
  (`crates/threepm-protocol/tests/fixtures/wire/v1/`) gate additive-only
  growth in CI; `docs/protocol.md` documents transport, versioning rules,
  the message catalogue and which `--json` fields are stable.
- `3pm sendSignal <SIG> <target> [--group]` (aliases `send-signal`,
  `signal`): deliver a signal to the running process (pm2) or its process
  group; not a lifecycle operation.
- `cron_restart` (ecosystem) / `--cron-restart` (start): restart on a
  cron schedule, 5 fields (pm2) or 6 with seconds (OxMgr); `show` prints
  the schedule and its next fire, `ls --json` carries
  `next_cron_epoch_ms`. Only online rows are restarted, like watch mode.
- Ecosystem profiles: `[defaults]`, `[apps.profiles.<name>]` (any
  field) and pm2's `[apps.env_<name>]`, selected with `--env <name>` on
  `start`/`apply`/`validate` (an undefined profile is an error);
  `--only a,b` on the same three. The file path and profile are recorded
  on the spec (`show`: `config file` / `profile`).
- `3pm validate <file>…`: check ecosystem files without starting
  anything (unknown fields are errors here, plus spec errors, duplicate
  names, dependency cycles, external `depends_on` — `--check-managed`
  asks the daemon); exit 1 on errors, `--json` findings.
- `3pm apply <file>… [--prune|--prune-all] [--dry-run]`: idempotent
  reconcile — create / update (restart with the new spec) / restart
  stopped / leave unchanged apps running; `--prune` deletes apps that
  came from the file and are gone from it.
- `3pm restart <target> --update-env`: re-read the app's ecosystem file
  (same profile) and restart on the fresh spec.
- Restart policies: `restart_policy = "always" | "on_failure" | "never"`
  in ecosystem files and `3pm start --restart-policy always|on-failure|never`.
  `on_failure` respawns only after a non-zero exit, a signal death, or a
  failed spawn; a clean `exit 0` leaves the app `stopped`. pm2's
  `autorestart = false` / `--no-autorestart` remain as the `never`
  shorthand (a contradiction between the two is a config error), and
  pre-existing `dump.json` files keep their meaning. `3pm show` gains a
  `restart policy` row.

### Fixed
- `hostmetrics` TCP state counts on Linux now read `/proc/net/tcp{,6}`
  and see every socket; pid-attributed enumeration missed other users'
  sockets and ownerless TIME_WAIT when running unprivileged.
- `hostmetrics` no longer counts Linux userland threads as processes
  (`processes_count`/`process_states` now match `ps ax`).

### Changed
- A typo'd `kill_signal` now fails at `start` with a did-you-mean hint
  instead of silently never taking effect.

### Added
- Watch mode: `3pm start --watch` (the cwd) or `--watch=PATH` (repeatable)
  restarts the app on file changes, with `--ignore-watch GLOB` (added to
  the built-in dotfiles + `node_modules`), `--watch-delay MS` (trailing
  debounce, floor 100ms) and a hidden `--watch-poll MS`; ecosystem keys
  `watch` (`true` | path | list), `ignore_watch`, `watch_delay`,
  `watch_poll`. Native OS events via `notify`, automatic polling fallback
  reported as `watch_error` on records (`ls` note, `show` row). One
  watcher per app, derived from state (no phantom "enabled"). `3pm restart
  <app> --watch` toggles like pm2; `ls` gains a `watching` column and
  `show` a `watch & reload` row.
- Exit context on crashes: `ls`/`show --json` records and the `process`
  `exit` event on the bus now carry `exit_signal` (POSIX name of the
  killing signal, e.g. `SIGSEGV`) and `stderr_tail` (the last 30 stderr
  lines, byte-bounded), next to `exit_code`. Both are cleared by the next
  successful spawn. `3pm show` prints the signal (or exit code) as a row
  and the stderr tail under the table; `3pm logs --json` forwards process
  events.
- `3pm hostmetrics` reports GPUs: model/vendor/driver, utilization,
  dedicated VRAM (NVIDIA via `nvidia-smi`, AMD via `/sys/class/drm`) or
  unified memory + core count + driver-recovered resets (Apple via
  `ioreg`), temperature, power, fan. Absent fields stay absent — cards
  no source can instrument still appear as identity-only rows.
- Child control channel (`ctl.sock`): apps can signal readiness and
  receive graceful-stop messages over a Unix socket (any language;
  env `THREEPM_CTL_SOCK`/`THREEPM_ID`/`THREEPM_GEN`). New start flags
  `--wait-ready` (+ `listen_timeout`, pm2 semantics) and
  `--shutdown-with-message`; all three settable in ecosystem files.
- Cluster mode: `3pm start -i N` runs N instances of one app (`-i 0` =
  one per CPU; also `instances = N` in ecosystem files), each with
  `THREEPM_INSTANCE`/`NODE_APP_INSTANCE` env and per-instance log/pid
  files; `3pm scale <name> <n>` resizes without restarting survivors.
  `3pm show` reports every instance of a name (`--json`: an array).
- `backoff_factor` is configurable from ecosystem files.
- Global `--json` flag: machine-readable output on stdout for all
  daemon-backed commands (NDJSON events for `logs`); errors become
  `{"error":{code,message}}` on stderr with unchanged exit codes.
- `3pm startup` / `3pm unstartup`: boot-service registration for
  launchd, systemd, OpenRC, and rc.d — prints the privileged command,
  service runs the daemon as the invoking user.
- `justfile` task runner, `CLAUDE.md`, release automation and
  distribution packaging (deb/rpm, APT/YUM repos, install script).

## [0.1.0] - 2026-07-07

Initial development version: pm2-workflow process manager as a single
`3pm` binary (CLI + auto-spawned daemon).

### Added
- Process lifecycle: `start` (scripts, binaries, ecosystem `.toml`/`.json`),
  `stop`, `restart`, `delete`, `ls`, `show`, `logs` (tail + realtime
  stream), `save`/`resurrect`, `kill`, `update`, shell completions.
- Supervision: auto-restart with exponential backoff (on by default),
  crash-loop protection via consecutive-quick-exit accounting,
  `--max-memory-restart`, per-app timing knobs, group-kill stop
  sequence with SIGKILL escalation.
- Robustness: pure lifecycle state machine, pid-reuse-safe
  signal/reap serialization, orphan reconciliation after unclean
  daemon death, capped byte-oriented log pumps, `flock` daemon
  singleton, version-skew detection on every connection.
- Security: sockets are `0600` and peer-uid-checked.
