# 3pm wire protocol

The contract between the `3pm` CLI (or any embedder of `threepm-protocol`,
or the Node.js client in `packages/node`) and the daemon. Source of truth for the shapes is
`crates/threepm-protocol/src/{message,control,filter}.rs` and
`src/shared/` (`actions`, `calls`, `live`, `attach`, `host`, `app_metrics`, `tree` —
what the fleet link and the backend read); the frozen JSON of
every message for the current protocol generation lives in
`crates/threepm-protocol/tests/fixtures/wire/v<N>/` and is enforced by
`tests/wire_fixtures.rs`. This page explains the rules; the fixtures are
the byte-level reference.

## 1. Transport

Three Unix domain sockets under `$THREEPM_HOME` (default `~/.3pm`), all
created `0600`/`0700` and **peer-uid checked**: the daemon calls
`SO_PEERCRED`/`LOCAL_PEERCRED` on every accepted connection and drops any
whose uid is not the one that listener serves (`bootstrap::peer_uid_is`).
On its own sockets that uid is the daemon's effective uid; a root daemon
also listens for the users it hosts (*Hosted users* below), one set of
sockets each, and every accepted connection therefore carries an
identity.

| Socket | Direction | Framing | Purpose |
|---|---|---|---|
| `rpc.sock` | client → daemon, one `Request` ⇒ exactly one `Response` | NDJSON | Commands (`ls`, `start`, …). A connection may carry many sequential request/response pairs. |
| `rpc.sock` after `attached` | both directions | live framing | An `attach` answered `attached` takes the connection over: from the next byte on, both sides speak the live framing (§ *Attach*). The only request that does so (`Request::takes_connection`). |
| `pub.sock` | daemon → subscriber, fire-and-forget | NDJSON | The event bus (`Event`): log lines, lifecycle transitions, rotations. A slow subscriber receives `lagged` instead of blocking the daemon. |
| `ctl.sock` | supervised child ↔ daemon | NDJSON | Optional control channel (`ControlIn`/`ControlOut`): readiness, graceful shutdown. Gen-pinned (see §4). |
| `host.sock` | daemon → host metrics agent, one `Request` ⇒ one `Response` | NDJSON | Internal: the same `Request`/`Response` types, restricted to `ping` and `host_metrics` (anything else is `err{unknown_request}`). Version-checked with the same `pong` handshake. |

**NDJSON**: one JSON object per `\n`-terminated line, UTF-8, no framing
bytes. **Line cap**: a line longer than `MAX_LINE_BYTES` (1 MiB, newline
excluded) is a `CodecError::LineTooLong` and the connection is closed —
never buffered further. What a client reads back on `rpc.sock` is held
to `MAX_REPLY_BYTES` (32 MiB) instead (`read_msg_capped`; the Node
client's `RpcConnection` mirrors it): a `list` reply carries every row
(~1.5 KB each), which the 1 MiB cap would stop at about 700 apps.
Requests, control messages and the event stream keep 1 MiB.
`LineTooLong` names the cap it hit. A malformed line is a `Malformed` error on the
reader; on `rpc.sock` the daemon answers `{"res":"err","code":
"unknown_request",…}` for JSON it cannot map to a `Request` rather than
hanging up.

Liveness is always "connect to `rpc.sock` and get a `pong`"; pidfiles are
never consulted (`client::connect`). Daemon auto-spawn and the readiness
byte on fd 3 are described in OV `3pm-docs-archived/architecture` §4.

### Hosted users (one root daemon, per-user sockets)

A **root** daemon serves, besides its own sockets, one `rpc`/`pub`/`ctl`
set per user declared in `[users] allow` of its `config.toml`. They live
in a root-owned home under `/var/lib/3pm/users/<name>/` (`[users] dir`
moves the parent), the standard `Paths` layout plus a `hosted` marker
file; the home and its `logs/`, `pids/`, `actions/` are `root:<gid> 0750`
and only the three sockets are chowned to the user (`0600`). A daemon
that is not root hosts nobody and ignores `[users]`.

Each listener is peer-uid checked against **that** user's uid, so the
daemon knows who is calling: `Caller::Root` on its own sockets,
`Caller::Hosted(<name>)` on a user's. The caller decides four things:

| What it decides | For a hosted connection |
|---|---|
| which requests are served | the app-level ones. The daemon-level commands (`save`, `resurrect`, `kill_daemon`, `internals`, `orphans`, `handoff`) answer `err{forbidden}`. `host_metrics` is served — the figures are the machine's — minus `top_processes` and `sessions`, which name the other accounts |
| which rows it resolves | only rows whose `spec.owner` is its own name — in `list`, `describe`, every `Target` (`all` included) and every log operation. A row of another owner answers `err{not_found}`, never `forbidden`: the reply is not an existence oracle. `flush_logs` with a null target spares the daemon's `3pm.log`, and `reload_logs` reopens only its own files |
| which events it receives | the events of its own rows, plus the daemon-level ones (`lagged`, `daemon_shutdown`, `daemon_handoff`). The `EventFilter` handshake is unchanged — it narrows, never widens |
| what a `ctl.sock` hello attaches to | a row whose `spec.user` is that user (and whose `gen` is current, as always) |

What it does **not** decide is the fleet. A hosted account places `call`s
on other nodes exactly as root does — same refusals, same codes, same
words — and there is no setting to turn that on: the right comes with
being hosted. Its bytes travel on the data socket the agent binds for its
uid (§ *Calls*, `hosted_socks`). Being hosted still narrows the call in
the two ways above the table: the rows it may target locally, and — for
an input it names by `path` — the files of its own uid.

A hosted listener serves at most **64 connections at once** (root's
descriptor budget is not a hosted user's to spend); further peers are
hung up on and the refusal is logged. A client in a hosted home never
spawns a daemon — the root one owns that home — and an in-place handoff
(`3pm update`) does **not** carry the hosted listeners across the exec:
the new image rebinds them, so a hosted client sees a short gap, waits up
to 5 s for the socket to answer again, and reconnects.

## 2. Versioning and compatibility

Two numbers travel in `pong`:

| Field | Meaning | Checked? |
|---|---|---|
| `version` | `CARGO_PKG_VERSION` of the daemon build | No — informational, shown in skew messages and `3pm update` |
| `protocol` | `PROTOCOL_VERSION` (`threepm_protocol::PROTOCOL_VERSION`, currently **2**) | **Yes** — `connect()` accepts iff it equals the client's |

A `pong` without `protocol` (a daemon predating the field) decodes as `0`,
which never matches: the user is told to run `3pm update` (save → kill →
respawn → resurrect). Only `3pm kill` and `3pm update` use
`connect_unchecked()`, since they must talk to the outdated daemon.
`pong` itself never moves between generations — it is the message that
announces which one is spoken.

`pub.sock` is not version-checked: events decode leniently, so a skewed
daemon degrades to "some events invisible", and every CLI path that
subscribes has already done a checked `connect()`. Its only handshake is
optional and one-way — see *Subscription filter* below.

### Growth rules (what does NOT bump `PROTOCOL_VERSION`)

Compatibility is **additive-only**. Within one protocol generation you may:

- add a `Request` variant — an older daemon answers
  `err{code:"unknown_request"}` (never drops the connection);
- add a `Response` variant — an older client decodes it as
  `Response::Unknown` via `#[serde(other)]`;
- add an `ErrCode` — older clients map it to `ErrCode::Unknown`;
- add an `Event` / `ControlIn` / `ControlOut` kind — the other side skips
  it (`decode_lenient`);
- add a field to any struct or struct-variant, provided it is
  `#[serde(default)]` (and, if it should stay terse on the wire,
  `skip_serializing_if`). `DaemonInternals`, `HostMetrics` and every
  `Host*` struct are container-level `#[serde(default)]`: they decode from
  `{}`.

You must **bump `PROTOCOL_VERSION`** (and start a new
`fixtures/wire/v<N>/` directory) when you:

- remove or rename a variant, a field, or a tag value;
- change a field's type or meaning;
- make a previously optional field required;
- add a value to an enum carried **inside** a field when that enum has no
  `#[serde(other)]` catch-all. These are closed today: `Status` and
  `ProcessEventKind` (`threepm-core/src/process.rs`), `LimitScope`
  (`threepm-core/src/spec/limit_scope.rs`), `LogStream`. An older peer
  does not skip the unknown value, it fails the whole message: one new
  status in a `ProcessRecord` breaks an old CLI's `list`. (The fleet link
  solved this with lenient mirrors, `threepm-link/src/wire/lifecycle.rs`;
  the local wire has not.)

The fixture tests turn this into a CI gate: every frozen line must still
decode, and every key a frozen line carried must still be emitted by the
current samples. Regenerate after an additive change with
`UPDATE_WIRE_FIXTURES=1 cargo test -p threepm-protocol --test
wire_fixtures -- --ignored update_fixtures` and review the diff as
additions only. The generator writes `v<PROTOCOL_VERSION>/` and nothing
else, so `update/` (below) can never be regenerated by accident.

### 2.1 The frozen `update/` subset

`3pm update` is the one path that talks to a daemon of **another**
generation: the installed CLI asks the running daemon to hand off to the
new binary, then keeps talking to the image that comes back. It works
because none of its connections is version-checked —
`connect_identity()` for `update`, `connect_unchecked()` for `kill`, and
`probe()` inside `wait_daemon_back` — and because the handoff manifest
(`HANDOFF_VERSION`) carries no `Request`, `Response` or `Event`.
So a daemon of one generation hands off to a binary of the next, and
back again for a rollback, without restarting a single app. The
manifest's own version is 2 only when a row has `tty` (running or
stopped — the spec decides, so the pre-check's draft and the final
manifest agree; a running one's stream is a pty master, which an older
image would close); every other manifest is still written as 1, which
every image reads. An image
that cannot read a version-2 manifest refuses it at the pre-check, and
`3pm update` falls back to a cold restart.

That makes seven requests and six responses a **permanent** contract,
frozen in `tests/fixtures/wire/update/` and never regenerated:

| Request | Response read | What the update path reads out of it |
|---|---|---|
| `ping` | `pong` | `boot_id` (is this the successor image?), `protocol`, `version` |
| `list` | `process_list` | `processes[].name`, `.instance`, `.pid` — the kept-vs-restarted verdict, nothing else |
| `internals` | `internals` | `last_handoff` (`{outcome, error}`): "the new image adopted" vs. "the old one resumed" |
| `handoff` | `ok` / `err` | the `res` tag, `code` (`unknown_request`, `handoff_in_progress`, `handoff_refused`), `message` |
| `save` | `saved` | `count`, read leniently: the old daemon may predate this CLI |
| `kill_daemon` | `ok` | `affected: ProcessRecord[]` — the cold path's cross-generation decode |
| `resurrect` | `ok` | the same shape, against the freshly spawned daemon |

`ProcessRecord` is therefore load-bearing across the boundary, and so is
`DaemonInternals` — the second one silently: both are container-level
`#[serde(default)]`, so a generation that regrouped their fields would
not error, it would answer `mode: "recovered"` on the very update that
proves the handoff worked. That is why neither is restructured, and why
these thirteen messages are pinned by bytes rather than by taste.

Two tests hold it, and they hold different halves.
`wire_fixtures.rs::the_update_subset_is_frozen` pins the **bytes**: the
tags of `update/` and the sha256 of each file, written in the test, so
editing the fixtures means editing the constant beside them.
`threepm-cli`'s `commands/update_frozen_tests.rs` pins the **meaning**:
today's `Request` writes those very requests, and the `expect_*` and
`match`es `3pm update` runs read those very responses. A generation that
breaks either one has to write the `serde_json::Value` shim the plan
describes before it ships — v2 needed none, because it changed none of
the thirteen documents.

## 3. Message catalogue

Tagging: `Request` uses `"cmd"`, `Response` uses `"res"`, `Event` uses
`"event"`, control messages use `"msg"`; all tags and enum values are
`snake_case`. `Target` is externally tagged: `{"id":3}`, `{"name":"api"}`
or the string `"all"`.

### Requests (`rpc.sock`, `cmd`)

| `cmd` | Fields | Reply |
|---|---|---|
| `ping` | — | `pong` |
| `start` | `spec: StartSpec`, `if_changed: bool` (apply semantics) | `ok` |
| `scale` | `name: string`, `instances: u32` | `ok` |
| `stop` | `target` | `ok` |
| `restart` | `target`, `toggle_watch: bool`, `patch?: SpecPatch` (elided when absent; folded into the matched apps' specs before they restart — pm2's attribute update, `patch.name` renames in place and is refused on a multi-app target) | `ok` |
| `rename` | `target`, `name: string` (the one app `target` matches is renamed in place, **without** a restart: same ids and pids, the pid files and the default log files follow; refused on a multi-app target, a module app, or a taken name) | `ok` |
| `delete` | `target`, `flush: bool` (also remove log files) | `ok` |
| `list` | — | `process_list` |
| `describe` | `target` | `process_list` (every instance of a name) |
| `save` | — | `saved` |
| `resurrect` | — | `ok` |
| `kill_daemon` | — | `ok` (reply waits for the children to die) |
| `internals` | — | `internals` |
| `host_metrics` | — | `host_metrics` (proxied from the host agent's cache; `err{unavailable}` when the agent is down) |
| `flush_logs` | `target?` (null = every app + `3pm.log`) | `logs_op` |
| `reload_logs` | — | `logs_op` |
| `reset` | `target` | `ok` (records with zeroed counters) |
| `send_signal` | `target`, `signal: string` (POSIX name), `group: bool` | `ok` |
| `rotate_logs` | `target?` (null = all) | `logs_op` |
| `orphans` | — | `orphans` (read-only scan of `pids/`: live processes of a previous daemon that no managed row owns) |
| `app_metrics` | `id` | `app_metrics` — the instance's application metrics, **always whole**: the daemon cuts no diff |
| `handoff` | `exe: string`, `argv: string[]` (default empty) | `ok` (the rows handed over) once the new binary passed its pre-check and the daemon is about to re-exec in place (`3pm update` without app restarts); the connection then EOFs and a later `pong` shows a new `boot_id`. `err{handoff_refused}` when the binary or the moment is unfit, `err{handoff_in_progress}` while one is already running |
| `process_trees` | `ids: u32[]` (default empty = every instance), `gen?: u64` | `process_trees` — the process tree of each instance as of the last metrics tick (`3pm ls --tree`, `3pm show`, the fleet agent). An unknown id is left out, and with `gen` so is an instance whose current incarnation differs, so a tree is never attached to a restarted row — however many ids were asked for: alone, that is an empty `trees`, never an error. A daemon predating trees answers `err{unknown_request}` |
| `call` | `node?: string` (a node id `3pmn_…` or a hostname unique in the bucket; absent = this host), `app: string`, `instance?: u32` (default 0; **required** once there is an `input` — a 100 MB file is not fanned out), `source?: string`, `action: string`, `params` (JSON, default `null`, ≤ 64 KiB serialised), `timeout_ms?: u64`, `input?: CallInput`, `output: bool` (ask for a result file), `stream: bool` | `called` — a `dispatch` with bytes attached, on this host or on another node of the bucket (`3pm call`, `3pm trigger --file`). `CallInput` is `{path?: string` (a file of this host, read by the daemon)`, bytes?: u64, sha256?: string, ext?: string, live: bool` (default false)`}`: `bytes` present means a file (checked by the receiver), absent means a live stream (framed, *Live framing* below). The declared size is bounded by the action's `input.max_bytes`, itself ≤ 8 GiB, and the call is refused before a byte travels when it exceeds it. **Served** (`supervisor/rpc.rs` dispatches `Command::Call` to `handle_call_request`; e2e in `crates/threepm-cli/tests/{e2e_call,e2e_call_refusals}.rs`). A daemon predating the request answers `err{unknown_request}` |
| `attach` | `target` (a name or an id; `all` is refused), `instance: u32` (default 0), `read_only: bool` (default false), `mode` (`rendered` default, `raw`; unknown → `unknown`, refused), `peek: bool` (default false), `cols: u16`, `rows: u16` (default 0 = no size) | `attached`, then the connection switches to the live framing (§ *Attach*). `err{not_found}`, `err{not_tty}`, `err{unavailable}`, `err{invalid_spec}`. A daemon predating it answers `err{unknown_request}` |
| `dispatch` | `target` (a name or an id; `all` is refused), `instance?: u32`, `source?: string`, `action: string`, `params` (JSON, default `null`, ≤ 64 KiB serialised), `timeout_ms?: u64`, `remote: bool`, `input?: ActionInput`, `output?: LocalTicket`, `stream: bool` | `called` — run a custom action the app declared on its control channel, with its tickets **already prepared** (the fleet agent, `3pm trigger`, `3pm profile`). That is the whole difference from `call`: `call` names one instance and asks the daemon to prepare the bytes, `dispatch` fans a name out and carries the tickets the agent already minted (`input`, `output`, `stream`; a plain `3pm trigger` names none of them). A name without `instance` reaches every instance whose control connections declare the action (at most 16, else `err{invalid_spec}`); `source` picks the connection when several of one instance declare it (else `err{invalid_spec}` listing them). `timeout_ms` is bounded by the declaration's (default 30 s, ceiling 10 min). `remote` marks a call from the fleet backend: an action declared `remote: false` answers `refused`. `err{not_found}` when nothing declares the action (the message lists what is declared), `err{handoff_in_progress}` during a handoff. A daemon predating actions answers `err{unknown_request}` |

Any request may instead get `err`. While the daemon shuts down only
`ping`, `list` and `kill_daemon` are served; everything else is
`err{shutting_down}`.

**`start` and accounts.** Three `StartSpec` fields carry the hosted-user
model. `user` (optional) is the Unix account the app runs as: honored by a
root daemon, and only for a user it hosts — anything else is
`err{invalid_spec}`, never "runs as the daemon". `persist` (default
`true`, elided on the wire when true) is whether `dump.json` keeps the
app: `false` means it survives restarts and handoffs but is never
relaunched at boot. `owner` (optional) is **stamped by the daemon on every
entry path** — `start`, `if_changed`/apply, a patched `restart`, a rename,
`scale` — from the connection's identity: a hosted caller's own name,
absent for root. A client may send one; it is overwritten. A hosted
caller's spec is narrowed at the same time: `user` forced to its own
account, `source` and `module` cleared (they are provenance, and both
would put root to work on the user's behalf), `out_file`/`error_file`/
`log_file` refused, `watch` roots bounded and kept inside `cwd`, and at
most `[users] max_apps` rows per owner (default 32) — else
`err{forbidden}`. App names stay global: a name another owner holds is
`err{invalid_spec}` ("belongs to …"), for root as for a hosted user.

### Responses (`rpc.sock`, `res`)

| `res` | Fields |
|---|---|
| `pong` | `pid: u32`, `version: string`, `protocol: u32` (default 0), `boot_id: u64` (default 0) — a random id of the daemon *image*, redrawn when the daemon re-executes itself in place (`3pm update` handoff: same pid, new binary), so a client can tell "the daemon I was talking to" from "its successor" |
| `ok` | `affected: ProcessRecord[]` — the rows acted upon |
| `process_list` | `processes: ProcessRecord[]` |
| `saved` | `path: string`, `count: usize` |
| `internals` | `internals: DaemonInternals` |
| `host_metrics` | `host: HostMetrics` |
| `logs_op` | `touched: string[]` — files flushed / reopened / rotated |
| `orphans` | `orphans: OrphanRecord[]` — `{label, pid, pid_file}`, container-level `default`; `label` is the pid file's stem (`api`, `api-2`) |
| `app_metrics` | `id`, `gen`, `rev`, `sources: {source, pid?, connected_at_epoch_ms}[]`, `def: SeriesDef[]` (every series: `sid`, `name`, `kind`, `unit?`, `description?`, `source?`, `labels`), `val: {sid, value, updated_at_epoch_ms}[]` (every scalar), `hist: {sid, scale, count, sum, zero, buckets: [index, count][], updated_at_epoch_ms}[]` (every histogram, every bucket). `rev` moves only when something a reader sees moved (a batch that changes nothing is not a new revision): it is what `ProcessRecord.app_metrics_rev` reports, so a reader knows whether asking again is worth it |
| `process_trees` | `trees: AppTree[]`, one per instance answered, in id order. `AppTree`: `id`, `gen`, `rev` (the record's `tree_rev` at the tick), `cpu_percent` and `mem_bytes` (this tick's totals over every member, omitted ones included — the same moment and incarnation as `procs`, which a separate `list` may not be), `procs: TreeProc[]` (`procs[0]` is the spawned pid; a parent precedes its children, siblings by pid, members whose parent is not a member follow by pid; empty when the instance has at most one process or is not running), `omitted: u32` (elided at 0; members cut by the caps, their figures still in the totals). `TreeProc`: `pid`, `ppid` (as the kernel reports it, not necessarily a member), `name` (the name the OS gives the process now, read again every tick: `comm` on Linux — the executable's file name after an `exec`, or what `prctl(PR_SET_NAME)` / Node's `process.title` set since, 15 bytes —, the executable's file name on macOS — it follows an `exec` only —, the name first seen elsewhere; ≤ 64 bytes, control and bidi-control characters and the line/paragraph separators blanked to a space, private-use glyphs kept — a Nerd Font icon in a `process.title` —, never the arguments — text the app controls), `cpu_percent` (percent of one core, 0 on a member's first tick), `mem_bytes` (RSS), `started_at_epoch_ms` (second resolution; uptime and identity against pid reuse), `detached: bool` (elided when false; the process left the app's process group — `3pm stop` will not reach it; never on `procs[0]`), `listen_ports: u16[]` (elided when empty; at most 4, the record's `listen_ports` is the uncapped union), `ext` and `net` (elided when absent: Extended Monitoring's counters, the process's network traffic as `NetUsage` — `rx_bytes_per_s`, `tx_bytes_per_s`, `rx_bytes`, `tx_bytes`, `partial` elided when false; everything the process moved, its connections with the app's other members included, which the record's `net` leaves out; both shed from the reply's tail by `budget_extras` when it would overflow, `net` reserving 192 B a line). Caps: 256 lines per tree, 2 000 per reply shared as `min(256, 2000 / n)` per instance, `n` being the instances answered that list members (at least one line each; an instance alone or stopped takes no share), so a reply never drops an instance whole and stays under the line cap at the worst-case line size. Every struct is container-level `default` |
| `called` | `call_id?: string` (the id the fleet knows the call by; a `call` always carries one, a `dispatch` none — it mints one id per instance it reaches and no one of them is the answer's), `ok: bool`, `results: ActionOutcome[]` (default empty), `err?: string` and `code?: ActionErrCode` (present iff `!ok`, elided otherwise), `output?: {path, bytes, ext}` — the one answer both `call` and `dispatch` get. **The verdict is the call's, not a handler's**, and only the `call` leg gives one: a call that never reached a handler (`node_offline`, `too_large`, `refused`, `timeout`, `lost`, …) answers `ok: false` with empty `results` and a `code` from the same vocabulary `ActionOutcome.err.code` uses, so a caller reads one set of words whatever went wrong. A handler that ran and failed leaves `ok: true` here and says so in its own outcome. A `dispatch` that reaches handlers is `ok: true` with one outcome per instance, in id order; one refused as a whole is an `err{…}`, like anything that is not a call at all — an unknown app, a malformed request, a daemon shutting down. `output` is present when a `call` asked for one and the handler produced it; the file is already on this host, under `actions/`. `ActionOutcome`: `proc_id`, `name`, `instance` (default 0), `source` (the control connection that ran it), `action`, `duration_ms`, `ok`, and when `ok` `data?` (the handler's JSON, ≤ 512 KiB) and/or `file?: {path, bytes, ext}` — both together when the handler left a file and a small descriptive `data` — (an absolute path under `<home>/actions/<app>[-<n>]/` — an app name made only of dots is written `%2E` per dot —, `<epoch_ms>-<call>.<ext>` with `call` the call id, 16 lowercase hex digits, ≤ `ACTION_FILE_MAX_BYTES` — **8 GiB, the same ceiling as an input**, because nothing copies the file here: the daemon checks its size and hands the path over. Leaving the host is capped far lower and separately, by the fleet and not by this protocol: an action's declared `output.max_bytes` when the file crosses as a blob, and the backend's artifact caps — 32 MiB by default — when the agent uploads it. Retention: *Control channel* → *Files*); `output?: {blob, bytes, ext, sha256}` is the other road bytes come back by — the blob a handler of this host pushed for a caller that may be elsewhere, present on a `call` and never on a `dispatch`, and the one an outcome keeps when it is forwarded (a path belongs to the host that wrote it); when not `ok`, `err: {code, message}` with `code` one of `failed` (the handler threw — `message` is its text, cut to 1 KiB —, or the daemon could not prepare the call or accept its reply: then `message` starts with `call not prepared: ` (the instance directory could not be created) or `reply not accepted: ` (the announced file is missing or not a regular file — a link is refused —, or its extension is invalid)), `timeout`, `busy` (the action already runs on that source, the source runs `CALLS_PER_SOURCE_MAX` (16) calls, the daemon `CALLS_PER_DAEMON_MAX` (32) plain ones — sessions are counted apart, `STREAMS_PER_DAEMON_MAX` (16) —, or the connection's queue has no slot left but the one kept for `shutdown` — retryable), `gone` (the connection closed, the source reconnected, the process exited, the daemon handed off, or the call ended without an outcome, before a reply), `refused` (a remote call on a `remote: false` action), `too_large` (`data` or the file over its cap); a `call` adds seven more, listed under *Calls* below; unknown codes decode as `unknown` |
| `err` | `code: ErrCode`, `message: string` |
| `attached` | `id`, `name`, `instance` (default 0), `pid?` (absent when the app is not running: the client sees its last screen and waits), `gen`, `alive: bool`, `cols: u16`, `rows: u16` (the pty's size), `viewers: u32` (this client included, default 0), `bracketed_paste: bool` (the app enabled it; default false) — the answer to `attach`; the live framing starts on the next byte |
| *(unknown)* | decoded as `Response::Unknown`; never sent by the daemon |

`ErrCode`: `not_found`, `invalid_spec`, `spawn_failed`, `unknown_request`,
`shutting_down`, `internal`, `unavailable` (a delegate the daemon
depends on — the host metrics agent — is unreachable or has no sample
yet; retryable), `handoff_in_progress` (the daemon is handing off to a
new image — `3pm update` without app restarts — and refuses mutations
for a moment; reads pass; retry once a `pong` shows a new `boot_id`),
`handoff_refused` (the daemon declined to hand off; the message says
why), `forbidden` (the caller may not do this: a hosted user's connection
asked for a daemon-level command — see *Hosted users* — or for an
account it may not run as), `not_tty` (an `attach` to an app that does
not run on a terminal); anything else decodes as `Unknown`.

`HostMetrics.collected_at_epoch_ms` (optional) stamps when the agent took
the snapshot's fast tier; absent from a daemon predating the agent.
`rate_window_ms` and the per-NIC / per-mount `*_per_s` rates (all
optional) are derived by the agent from two consecutive fast samples.
`sampled_at_epoch_ms` (optional object of optional `fast` / `medium` /
`slow` / `extended` / `top` stamps) tells how old each tier's data is; absent from a daemon
predating tiered sampling. `3pmDaemon` (optional; the one camelCase key,
a Rust field cannot start with a digit) is 3pm's own footprint as the
agent measured it: `at_epoch_ms`, `processes[]` (`role`, `pid`,
`cpu_percent`, `mem_bytes`, optional `uptime_ms` / `open_fds` /
`threads`) and an optional `runtime` (the health subset of
`DaemonInternals`, pulled by the agent over `rpc.sock`; absent for a
tick the daemon did not answer). Absent from a daemon predating
self-metrics; every struct inside grows field-by-field.

### Events (`pub.sock`, `event`)

| `event` | Fields |
|---|---|
| `log` | `id`, `name`, `stream` (`out` \| `err`), `line`, `instance: u32` (default 0), `ts: u64` ms epoch (the daemon's receive time; the CLI stamps a replayed history line with the live log file's last write — see `docs/logs.md`) |
| `process` | `id`, `name`, `kind` (`start`, `online`, `exit`, `restart`, `stop`, `delete`, `errored`), and for `exit` only: `exit_code?: i32`, `exit_signal?: string`, `stderr_tail: string[]` (elided when empty) |
| `log_rotated` | `id`, `name`, `instance: u32` (default 0), `path` — `path` was renamed to `path.1` and reopened |
| `lagged` | `missed: u64` — the subscriber fell behind the bus |
| `daemon_shutdown` | `reason` (`kill`, `signal`, `home_removed`; unknown → `unknown`) — sent once, before any child is signalled; the socket closes when the drain completes. An EOF *without* it is an unannounced death |
| `app` | `id`, `name`, `instance`, `ts` (daemon receive time, ms epoch), `source?`, `event_name`, `level` (`debug`, `info`, `warn`, `error`, `fatal`; unknown → `unknown`), `severity_number` (OpenTelemetry: 5/9/13/17/21), `attrs` (flat scalars), `exc?: {type?, message?, stacktrace?}` (the instance's `cwd` and `$HOME` replaced by `.`/`~` in the trace), `fingerprint?`, `repeat` (events of the same fingerprint the rate limiter suppressed since the previous one) — an application event reported on the control channel |
| `daemon_handoff` | `phase` (`begin`, `done`, `failed`, `emergency`; unknown → `unknown`), `from_version`, `to_version` — an in-place handoff (`3pm update`): `begin` from the old image just before the exec, `done` from the new one once the rows are adopted, `failed`/`emergency` from whichever image ended up in charge after a problem (a resumed old image, or a new image that had to restart the apps). Daemon-level, like `daemon_shutdown` |
| `proc_action` | `id`, `name`, `instance` (default 0), `ts` (ms epoch, when the call settled), `source?`, `call` (the call id), `action`, `origin` (`local`: `3pm trigger` / `3pm profile`; `remote`: the fleet backend through the agent; unknown → `unknown`), `ok`, `err_code?` (an `ActionOutcome.err.code`), `duration_ms` — one custom action ran (or failed to) on one instance: the audit line. Neither the params nor the result ride the bus. A `trigger` refused as a whole once it named instances (several sources of one instance declare the action, none declares it, more than 16 instances) leaves one line per instance it resolved: `ok: false` without `err_code` (no call ran; the `err` answer says why), `duration_ms` 0, `source` the one asked for when it is a valid source name, a fresh `call`. One refused before naming an instance (`all`, an invalid action name, params over their cap, an unknown app or instance) leaves none: there is no process to write it about |

#### Subscription filter (`pub.sock`, subscriber → daemon, `event_filter`)

A subscriber may send **one** `EventFilter` line right after connecting;
the daemon waits at most 500 ms for it, then streams. No line, a late
line, an over-long (>64 KiB) or undecodable one all mean "everything" —
the daemon never hangs up over the handshake, and a daemon predating the
filter simply never reads the line. The broadcast cursor is attached
before the wait, so nothing published during the handshake is lost.

| Field | Meaning |
|---|---|
| `subscribe: string[]` | `*`/`?` globs over the event name; a `process` event also matches `process:<kind>` (`process:exit`) and a log line `log:<stream>` (`log:err`). Empty = every kind |
| `process?: string` | only events about this app name |
| `ids: u32[]` | only events about these process ids (what `3pm logs` sends). Empty = no restriction |

`process`/`ids` never scope out daemon-level events (`lagged`,
`daemon_shutdown`); only `subscribe` can. Every field is `serde(default)`
(additive growth); unknown fields are ignored.

A pattern is a user's word, not a wire shape: nothing decodes it. So the
three spellings a v1 subscriber could type — `log_out`, `log_err` and the
`log_*` glob — are still accepted, for one release, as `log:out`,
`log:err` and `log*` (`filter::aliased`). `log*` covers exactly what
`log_*` covered in v1: both streams and `log_rotated`.

### Control channel (`ctl.sock`, `msg`)

The spawner sets `THREEPM_CTL_SOCK` (the daemon's socket path),
`THREEPM_ID`, `THREEPM_GEN` and `THREEPM_BOOT` in every app's
environment, after the app's own `env` so an app cannot forge them.
`THREEPM_BOOT` is the daemon's lineage, 16 lowercase hex digits drawn at
every cold start (first start, crash, cold `3pm update`, `resurrect`
into a fresh daemon) and kept by an in-place handoff — unlike `boot_id`,
which a handoff redraws. The process-tree sampler adopts a daemonized
descendant through its environment only when the socket, the id, the
gen and the lineage are this daemon's and the incarnation's
(`docs/metrics.md` → *Process trees*). The CLI and host-agent launchers
strip the four from what they pass on, and the daemon from its own
environment at start, however it was launched.

Child → daemon (`ControlIn`): `hello {id, gen, source?}` (required first
line; `gen` from `$THREEPM_GEN` pins the connection to one incarnation — a
stale gen is hung up; `source` names the process within a multi-process
app, `^[a-z0-9][a-z0-9_-]{0,31}$`, unique per instance — a second hello
with the same source replaces the previous connection), `ready`,
`metrics {m: entry[]}`, `event {name, level?, attrs?, exc?, fp?}`,
`actions {list: decl[]}`, `action_reply {id, ok, data?, error?,
file?, output?}`, `call {id, node?, app, instance?, source?, action,
params, timeout_ms?, input?, output, stream}` (the app calling an action
on its own behalf — the same fields as the `call` request, plus the `id`
that correlates the answers), `action_started {id}` and the two lines of
the fleet gate, `fleet_ready {node_id, cluster_id, data_sock, secret}`
and `forward_update {id, ticket?, result?}`. Daemon → child
(`ControlOut`): `shutdown` (graceful stop request when the spec has
`shutdown_with_message`, written to every attached connection; SIGKILL
escalation still follows), `action {id, name, params, timeout_ms, file,
deadline_epoch_ms?, input?, output?, stream_out?}`, `call_ticket {id,
call_id, put?, in?, out?, deadline_epoch_ms}`, `call_reply {id, call_id,
ok, results, err?, output?}`, `forward {id, app, call}` and
`hosted_socks {socks: [{uid, gid, path}]}`.

**Calls (bytes attached).** A `call` is a `dispatch` whose input or output
is too big for the 1 MiB line, or open-ended. The bytes never ride this
socket: they travel as a **blob** — a raw stream with a `put` and a
`get`, addressed by an id and authorised by a token the daemon mints per
call (`{blob, token}`, a *local ticket*). The daemon answers a `call`
with `call_ticket` (where the bytes go, and `deadline_epoch_ms`: the
transfer budget plus the handler's timeout) and later with `call_reply`
(the outcomes, or `err` when it never ran). On the handler's side the
`action` line grows three legs: `input` (`{path?, blob?, token?, bytes?,
sha256?, ext?, live}` — `blob` absent means the bytes are local and
`path` *is* the file), `output` (the local ticket for a result the caller
asked for; the handler names it back in `action_reply.output`, `{blob,
bytes, ext, sha256}`) and `stream_out` (a streaming call's outbound leg).
`action_started {id}` says the handler has the bytes and starts now: the
daemon rearms `timeout_ms` from that moment, **once** per call and only
from the connection the call went to, never past `deadline_epoch_ms`.
`forward` and `forward_update` are the daemon's dialogue with the fleet
agent for a call that leaves the host; `fleet_ready` is how that agent
proves itself (the `secret` is per daemon launch, injected in the agent
app's environment). The secret is only half of the gate: the daemon also
reads the peer's pid on the socket (`SO_PEERCRED`) and opens the gate
only for the very process it spawned for the agent's row. That is a
constraint on the agent, not only a guarantee: the gate's connection
must be opened by the process the daemon launched — never by a child of
it, never by a detached worker, never over a descriptor handed from one.
A forked session carries a different pid and the gate simply does not
open; the refusal says nothing to the peer (a forgery must learn
nothing), so the symptom is a `fleet_ready` that is ignored and a
`warn!` in the daemon's log naming the peer's pid. A root daemon that
hosts users writes `hosted_socks` on the gate each time it opens: one
data socket per hosted user, `<hosted home>/agent/data-<uid>.sock`, which
the agent binds for that `uid:gid` (0600) — the path the SDK of a hosted
app looks for first. The list is whole and authoritative, empty when the
daemon hosts nobody: the agent closes a socket it no longer names.

**Error codes a call adds.** `ActionErrCode` grows with seven names a
plain `dispatch` never produces; the SDKs read them straight out of `e.code`.

| Code | Meaning |
|---|---|
| `lost` | the call left this host and never came back: the link fell, a blob went idle, a stream timed out. **At most once**: it may or may not have run, and nothing retries it |
| `node_offline` | the target node has no live link to the fleet, or this host's agent has no fleet gate open |
| `node_unknown` | no node of the bucket answers to that id or hostname |
| `ambiguous` | the hostname names more than one node: address it by its `3pmn_…` id |
| `forbidden` | a fleet scope that was never granted, or a target whose `callers` list holds rules and none admits the caller |
| `agent_too_old` | the agent on this host predates fleet calls, so the bytes have no road |
| `daemon_too_old` | the daemon on this host predates calls and never answered the `call` |

The other codes keep their meanings from `called` above, and an
unknown one still decodes as `unknown` — the list is additive.

Bounds, all in `threepm_protocol::{actions, calls}`: input
`ACTION_INPUT_MAX_BYTES` 8 GiB, concurrency `ACTION_CONCURRENT_MAX` 16
(at most `ACTION_INPUT_EXT_MAX` 16 extensions listed), a stream's hard
lifetime `ACTION_STREAM_MAX_MS` 24 h, calls per source
`CALLS_PER_SOURCE_MAX` 16, streams per daemon `STREAMS_PER_DAEMON_MAX`
16 (counted apart from the `CALLS_PER_DAEMON_MAX` 32 plain calls, so a
day-long session cannot freeze the daemon). The transfer budget of a
sized call is
`transfer_budget_ms(bytes)` = the size at 128 KiB/s, clamped to
`[60 s, 24 h]` — a 100 KB file gets a minute, a 2 GiB one gets 4.5 h.

Every number above is frozen in
`crates/threepm-protocol/tests/wire_samples/limits.json` and enforced by
`tests/limits.rs`. None of them travels on the wire, so an SDK's copy is
the only thing that can drift; the file is the reference it checks
against, and it carries worked `transfer_budget_ms` examples (sizes as
decimal strings, as they do not fit a double) so a reimplementation of
the formula is testable too. Regenerate with `UPDATE_LIMITS=1 cargo test
-p threepm-protocol --test limits -- --ignored update_limits`: a changed
number is a contract change for every SDK and the backend, an added key
is additive.

**Custom actions.** A connection with a `source` declares the actions it
answers to with `actions` — the whole list each time (an empty list
withdraws them all; sent again after every `hello`): `decl` = `{name`
(`^[A-Za-z0-9_.:-]{1,64}$`; `3pm:` is the SDKs' prefix by convention),
`description?` (≤ 128, cut), `remote?: bool` (default `true`: the fleet
backend may call it), `timeout_ms?` (default 30 s, ceiling 10 min),
`input?: {max_bytes: u64, ext: string[]}` (the bytes this action takes;
absent means none and a call carrying an input is refused. `max_bytes`
is `1..=8 GiB`, each `ext` follows the reply's grammar, an empty list
accepts any), `output?: {max_bytes: u64}` (the most the file this action
leaves may hold, `1..=256 MiB`; the SDK refuses to commit past it with
`too_large`, and the fleet backend reserves this much for a caller that
asks for the file — 256 MiB when absent), `stream?: bool` (default false; the handler is a session,
not a function, and `timeout_ms` then bounds one direction's idle time
rather than the call), `concurrent?: u32` (default 1, `1..=16`: calls of
this action one source may run at once)`}`; at most 32 per source, no
duplicates, or the list is refused whole and the previous one stays
(`threepm_protocol::validate_decl` holds the per-declaration bounds). A sourceless connection cannot declare. The
daemon triggers one with `action`: `id` correlates the reply, `params`
is the caller's JSON (≤ 64 KiB), `timeout_ms` the effective bound, and
`file` a path the handler may use for a result too big for the line —
it writes `<file>.tmp`, renames it to `<file>` (a regular file: a link
is refused), and names it in the reply; it must not write either after
`timeout_ms`. `deadline_epoch_ms` (ms since the Unix epoch, the host's
clock; absent from an older daemon) is when the daemon settles the call
`timeout`: an `action` read after it — the app's loop was blocked while
the line waited in the socket — must not start its handler (no reply
is needed; one would be ignored). The daemon never writes an `action`
whose call settled while it was still queued. `action_reply {id, ok, data?, error?, file?: {bytes,
ext}}` answers within `timeout_ms`: `data` (≤ 512 KiB serialised) and/or
`file` (`ext` `^[a-z0-9]{1,16}$` other than `tmp`; the daemon renames to
`<file>.<ext>`) when `ok`, `error` (the handler's message) otherwise. A
reply from another connection than the one the `action` went to is
ignored; a late reply too. One call per (source, action) at a time
unless the declaration raised `concurrent`, 16 per source, never the
last slot of the connection's outbound queue
(kept for `shutdown`), the daemon-wide bounds and every error code:
`dispatch` / `called` above.

**Files.** `<home>/actions/` keeps at most 20 accepted files per
instance directory and 200 MB in all, oldest first (by name: the call's
start); the retention runs after every accepted file and hourly. A file
of a call in flight, or of a `dispatch` not answered yet, is never
removed but counts. An accepted file modified less than 10 minutes ago
is spared by the per-instance count (whoever just got its path — the
fleet agent uploading it — has that long) and goes for the 200 MB only
once every older file is gone. A `.tmp` or extension-less file that no
such call owns (a handler that renamed after its deadline), and any
link, are removed outright.

#### Live framing (a stream's messages)

A `file` blob is raw bytes with a size and a digest, so it needs no
framing. A `live` blob has neither, so its bytes carry their own
boundaries: one message is a **20-byte big-endian header** followed by
its payload (`threepm_protocol::live`, no I/O).

```text
u32 len | u8 flags | u8 hdr | u16 rsv | u32 seq | u64 ts_us | payload[len]
```

| Field | Meaning |
|---|---|
| `len` | payload bytes, at most `LIVE_MESSAGE_MAX_BYTES` (1 MiB). A longer one is refused outright — the relay answers `422` — and the check needs only the first six bytes, so the payload is never buffered |
| `flags` | `KEY 0x01` (the payload decodes on its own: a reader may join here), `END 0x02` (last message), `INIT 0x04` (decoder parameters — H.264 SPS/PPS, `ftyp`+`moov` — kept by the window and prefixed to every reader that joins, whatever its age), `DISCONT 0x08` (messages were lost: reset the decoder). Unknown bits are carried, never refused |
| `hdr` | header bytes, `LIVE_HEADER_BYTES` (20) today. A **larger** header is accepted and its extra bytes skipped — that is how the framing grows; a smaller one is refused |
| `rsv` | reserved, written as 0, not interpreted |
| `seq` | the writer's message counter; it may jump, and the gap is what a reader dropped |
| `ts_us` | when the writer produced the message, microseconds since the Unix epoch, so per-message latency is measurable in production. It does not fit a double: read it as a 64-bit integer |

Frozen vectors:
`crates/threepm-protocol/tests/wire_samples/live_frames.json`, enforced
by `tests/live_vectors.rs` and reused by every SDK that reimplements the
framing. Each entry gives `head` (the header in hex, extension bytes
included), the payload as `payload` (hex) or `payload_fill` (one byte
repeated `times`, so the 1 MiB vector stays small), `accepts` with the
`error` name when false, `re_encodable` (the vector must come back byte
for byte out of an encoder) and the `decoded` fields — `ts_us` as a
decimal string. Regenerate with `UPDATE_LIVE_VECTORS=1 cargo test -p
threepm-protocol --test live_vectors -- --ignored update_vectors`; a
changed vector is a wire break, only additions are reviewable.

A `metrics` entry: `n` name (`^[a-z][a-z0-9_.]{0,63}$`), `k` kind
(`gauge` default, `counter` — cumulative since process start —,
`histogram`), `u` unit (UCUM, ≤ 16), `d` description (≤ 128), `l` labels
(≤ 3 keys `^[a-z][a-z0-9_]{0,31}$`, values ≤ 64 chars, reserved keys
refused: `cluster`, `node`, `app`, `instance`, `ns`, `src`, `overflow`,
`vmrange`, `le`, `hostname`, `os`, `arch`, `agent_version`, `mount`,
`window`, `core`, `iface`, `blockdev`, `sensor`, `gpu`, `array`, `proc`,
`pid` — except
the label set that is exactly `overflow="true"`, an
SDK's own fold of its excess label sets, merged with the daemon's), `v`
value (finite) or `h` histogram `{s: scale (0..4, default
1), count, sum, z: zero count, b: {index: cumulative count}}` on the
base-2 exponential grid of `threepm_protocol::exphist` (bucket `i` =
`(2^(i/2^s), 2^((i+1)/2^s)]`, a value on a boundary belongs to the bucket
below; the grid is frozen — changing it bumps `PROTOCOL_VERSION`).
Entries are decoded one by one: a bad entry is counted, never fatal; a
known kind with an unusable body is skipped; after `hello`, a line that
is not JSON is skipped too (only an over-long line or an I/O error ends
the connection — one bad line must not cost a source its series). Every batch is the SDK's
full picture (idempotent). Caps and rate limits: `docs/metrics.md`,
"Application metrics". The bucket grid is shared with the SDKs as
`packages/exphist-vectors.json` (generated by
`crates/threepm-protocol/tests/exphist_vectors.rs`, frozen like a wire
fixture); the SDKs themselves: `docs/app-metrics.md`.

### Attach (a terminal, over `rpc.sock`)

`attach` connects a client to the terminal of an app started with
`tty = true` (`3pm attach`). The request names the row and the client's
terminal; the daemon answers `attached`, and from the next byte on the
connection carries **live messages in both directions** (*Live framing*
above: 20-byte header, payload ≤ 1 MiB) until either side closes. No
fourth socket, no base64: the connection is the session.

```json
{"cmd":"attach","target":{"name":"claude"},"instance":0,"read_only":false,"mode":"rendered","peek":false,"cols":120,"rows":40}
{"res":"attached","id":3,"name":"claude","instance":0,"pid":4242,"gen":2,"alive":true,"cols":120,"rows":40,"viewers":1,"bracketed_paste":false}
```

- `cols`/`rows` are 0×0 for a pipe or a peek (no resize), otherwise each
  within `2..=1000`; out of bounds is `err{invalid_spec}`.
- Refusals, in the order they are judged: `shutting_down`,
  `handoff_in_progress`, `forbidden` (never, today: `attach` is
  app-level), `invalid_spec` (`all`, an id with an `instance` other than
  0, an unknown `mode`, a size out of bounds), `not_found` (an unknown
  app or instance, and a row the caller does not see — never an
  existence oracle), `not_tty` (the app has `tty = false`),
  `unavailable` (a `tty` row that never ran). A refusal is an ordinary
  NDJSON answer, after which the daemon closes the connection. A socket
  that serves no takeover (the host agent's `host.sock`) answers
  `unknown_request`; so does a daemon predating the request. Through
  any door but `rpc.sock` the request is judged the same way and a
  grantable one answered `unavailable`.
- A client that gives a size and may type (neither `read_only` nor
  `peek`) resizes the pty to it: the last such client wins.
- It is a mutation for the handoff gate (`handoff_in_progress` during
  `3pm update`) and is never re-sent by the client after a cut.
- A handoff (`3pm update`) ends every attached connection: the terminal
  and the screen cross the `execve` (handoff manifest version 2), the
  sessions do not (`detached{daemon_shutdown}`, then close). `3pm
  attach` on a terminal waits for the new image
  (`wait_daemon_gone_or_back`) and sends a fresh `attach` at its
  terminal's size; a pipe or a peek ends there (exit 3).

Each live message's payload is **one kind byte, then its body**
(`threepm_protocol::attach`, pure; integers big-endian):

| Direction | Kind | Body | Meaning |
|---|---|---|---|
| client → daemon | `0x01 input` | bytes (≤ 64 KiB; more closes the connection) | typed into the terminal |
| | `0x02 resize` | `cols u16`, `rows u16` | brought within bounds; ignored from a `read_only` client |
| | `0x03 detach` | — | clean end (`detached{client}`, then close) |
| | `0x04 ping` | — | answered `pong` (a pipe client knows its input was written) |
| | `0x05 eof` | — | the caller's stdin is over; only `3pm fleet ssh <node> -- <cmd>` sends it (the command's stdin is closed), a daemon ignores it |
| daemon → client | `0x10 screen` | `parts u8` (`0x01` more parts follow, `0x02` continues the previous part), bytes | a whole screen: on arrival, after `restarted`, a resize or a lag. Split when over the framing's cap; the first part carries the live `KEY` flag |
| | `0x11 output` | bytes | `raw` mode: what the app wrote, in order |
| | `0x18 diff` | bytes | `rendered` mode: scrolled history, then the difference against what the client has |
| | `0x1c alt_screen` | `on u8` | the app entered / left the alternate screen |
| | `0x1d stderr` | bytes | never from a daemon: what a fleet command without a pty wrote on its stderr (`output` carries its stdout) |
| | `0x12 resized` | `cols u16`, `rows u16` | the pty's size changed |
| | `0x13 exited` | `code i32` (`i32::MIN` = none), `signal i32` (0 = none) | the incarnation exited; the client stays |
| | `0x14 restarted` | `pid u32`, `gen u64` | a new incarnation; a `screen` follows |
| | `0x15 detached` | `reason u8`: 0 `client`, 1 `deleted`, 2 `daemon_shutdown`, 3 `input_backlog`, 4 `not_tty_anymore`, 5 `too_slow` (unknown → `unknown`) | the end |
| | `0x16 lagged` | `dropped u32` (live `DISCONT` flag) | `raw` mode: output was lost; a `screen` follows |
| | `0x17 pong` | — | |
| | `0x19 text` | `cols, rows, cursor_row, cursor_col u16`, `alive u8`, `unmodelled u32`, UTF-8 lines joined by `\n` | the answer to `peek`, followed by `detached{client}` |
| | `0x1a title` | UTF-8 | the app's window title |
| | `0x1b bell` | — | BEL |

How the daemon serves it: a `resize` is brought within bounds and
coalesced (the last one within 50 ms applies); a `ping` is answered
once the client's earlier `input` is written to the terminal; an
`exited` comes after the app's last output was sent (its alternate
screen left, if it left it); `detached{daemon_shutdown}` precedes a
daemon's shutdown or handoff, `too_slow` a client that let 32 lifecycle
notes pile up, and a client that takes more than 10 s to read one
message is cut. `rendered` bytes never switch the alternate screen
(`alt_screen` does, the client writes `CSI ? 1049 h/l`); history rows
come first, cut to the client's width, as rows written at the top of
the screen then scrolled out by `\n` on its last row, so they land in
the client terminal's own scrollback (a client taller than the pty, a
second one, scrolls from its own last row, its rows below the pty's
blanked first). The cursor shape (DECSCUSR `CSI Ps SP q`) goes out when
the app changes it; the client resets it on the way out.

Growth: a kind a side does not know — or one of the other direction — is
**ignored**; a fixed-size body may grow at its tail (the extra bytes are
skipped); a body shorter than its kind needs is an error, never a panic;
`seq` and `ts_us` are filled as for any live message. All of it is
additive within protocol v2. Frozen vectors:
`crates/threepm-protocol/tests/fixtures/wire/v2/attach.json`, enforced by
`tests/attach_vectors.rs` (regenerate with `UPDATE_ATTACH_VECTORS=1 cargo
test -p threepm-protocol --test attach_vectors -- --ignored
update_vectors`; additions at the tail only).

## 4. `--json` stability

`3pm ls --json` / `show --json` / `start --json` etc. print
`ProcessRecord`s straight off the wire. Within a protocol generation:

**Stable (scripts may rely on these):** `id`, `name`, `instance`,
`status` (`launching`, `online`, `stopping`, `stopped`,
`waiting_restart`, `errored`), `pid` (null when not running), `restarts`,
`unstable_restarts`, `uptime_ms` (null unless online),
`created_at_epoch_ms`, `exit_code`, `exit_signal`, `namespace`.

**Present, additive-only, values may be refined:** `cpu_percent`,
`mem_bytes`, `listen_ports`, `stderr_tail`, `last_error`, `watch_error`,
`waiting_for`, `next_cron_epoch_ms`, `out_log`, `err_log`, `pid_file`,
`proc_count`, `tree_rev`, `tty_viewers` (clients attached to the
instance's terminal; `0` without `tty` and from a daemon predating it).
Under `tty` there is one output stream: `err_log` is empty.

`cpu_percent`, `mem_bytes` and `listen_ports` describe the instance's
**process tree** — the spawned pid and every descendant the daemon
attributes to it (membership rules: `crates/threepm-daemon/src/metrics/tree.rs`;
neither another daemon's child nor a survivor of an earlier daemon of the
same home is ever adopted through its environment, and on macOS another
user's process is never a member): cpu is summed
(100 = one core), memory is the sum of the members' RSS (an upper bound
when they share pages), ports are the union. `proc_count` (additive, `0`
from a daemon predating trees) is the number of live members, `1` for an
app running alone; `tree_rev` (additive) moves when the listed membership
changes — a pid enters or leaves the first 256 lines, a member detaches
or takes another name, the incarnation exits — never on cpu/mem, to the
next value of one counter per daemon: it never goes back within one
image, and a daemon started cold numbers from 0 again. An in-place
handoff carries the counter (no revision is ever reused) but not the
rows': an adopted row reports `0` until its next metrics tick, and the
daemon redraws `boot_id` — a cache keyed on the pair must watch
`boot_id` and drop what it holds when it changes. So, within
one image, `(gen, tree_rev)` says whether `process_trees` has a new shape;
members beyond the per-tree cap do not move it. The spawned pid's own figures
are the first line of its tree. `THREEPM_PROCESS_TREE=off` on the daemon
restores the pre-tree meaning (the spawned pid alone, `proc_count = 1`,
empty trees).

**Best-effort:** `spec` mirrors the daemon's resolved `StartSpec`
(`threepm-core`) field for field. It is exposed for inspection and
round-tripping through `3pm apply`, not as a stability contract: fields
follow the per-app config surface and are documented in `3pm start
--help` / the ecosystem reference, not here. Two provenance fields are
daemon-stamped rather than user-set and absent when they do not apply:
`spec.source` (`{path, profile}`, the ecosystem file) and `spec.module`
(`{name, version}`, the module that owns the app — additive, absent on
user apps and on daemons predating modules).

A failed command prints one error document on stderr —
`{"error":{"code":…,"message":…}}`, the wire `ErrCode` verbatim (`cli` for
a client-side failure) — and exits **1**. The one exception is
`not_found`, which exits **2**, so a script can tell "no such app" from
"it did not work". `forbidden` is deliberately *not* another exit code: a
hosted user reading `code: "forbidden"` learns it asked for a daemon-level
command, and every other failure keeps the code it always had.

`DaemonInternals` (`3pm internals --json`) and `HostMetrics`
(`3pm hostmetrics --json`) are growable snapshots: every field is
optional on decode and platform-dependent blanks are absent rather than
faked. Treat individual fields as present-when-available.

`DaemonInternals.internal_processes: InternalProcess[]` lists 3pm's own
processes (additive; absent from a daemon predating it): `role`
(`daemon`, `host-agent` — may grow), `command`, `pid?`, `status`
(`online`, `waiting_restart` — may grow), `uptime_ms?` (this
incarnation), `cpu_percent`, `mem_bytes` (0 before the first sample or
while not running), `restarts`. Each row is `serde(default)` like its
parent. The older `host_agent_pid` / `host_agent_restarts` stay.

`DaemonInternals.boot_id` (the same id `pong` carries) and
`DaemonInternals.handoffs` (in-place handoffs this image went through)
are additive; `0` from a daemon predating them.
`DaemonInternals.last_handoff` (optional) records the last handoff this
daemon (the pid, across images) concluded: `outcome` (`done`, `failed`
or `emergency`), `at_epoch_ms`, `duration_ms`, `rows`, `from_version`,
`to_version`, and `error?` on a failure. Absent until the first handoff.
`DaemonInternals.metrics_tick_ms` (additive, `0` before the first tick
or from a daemon predating trees) is how long the last metrics tick took
— the process-table sweep that prices the process trees.
`DaemonInternals.metrics_stalled_ms` (optional, additive) is how long
the metrics sweep in flight has been running, counted from when that
sweep began, present only once it is past `max(5 × period, 5 s)` — no
sample, so no resource guard, for that long (`docs/metrics.md`, **Retrieval model**). Absent while sampling
is healthy and from a daemon predating it.

Client expectations around a handoff (what `threepm_protocol::client`
implements): a request answered `err{handoff_in_progress}` may be
repeated until it is not (bounded, 15 s); a connection cut without a
reply while `handoff.json` sits next to the socket, or after such a
reply, may be reopened and the request sent again — the old image
processes nothing it refused, so no request is ever applied twice —
except `handoff` and `kill_daemon`, which must not be repeated. A
`pong` whose `boot_id` differs from the one first seen is the successor
image; the same `boot_id` with a `last_handoff` newer than the request
means the exec failed and the old image resumed.
