# 3pm — Extensibility & pm2 Feature Map

Written 2026-07-10, after the extensibility milestone. Records why the
architecture looks the way it does, which seams exist for new features, and
how the remaining pm2 surface maps onto them. Companion to
OV `3pm-docs-archived/architecture` (mechanisms) and `CLAUDE.md` (playbooks).

## 1. The audit (2026-07)

An architecture audit graded the codebase against pm2's full feature surface.
Verdict: the foundations (pure policy machine, single-actor daemon,
race-safety kernel, lenient wire) were sound, but four structural gaps would
make every future feature more expensive than the last:

1. **Closed response/output layer** — `Response` couldn't grow safely,
   `Err` handling was hand-copied per command, no machine output existed.
2. **Spec construction triplicated** — every per-app field was hand-mapped
   in the struct, the CLI flags, the TOML mirror, and a stringly
   `KNOWN_FIELDS` list, with silent drift.
3. **App ≡ one process** — one pid per state row, name-keyed files, no way
   to express pm2's flagship cluster mode or zero-downtime reload.
4. **No channel into the child** — only stdout/stderr and exit; nothing
   like pm2's `process.send` for readiness, graceful shutdown, metrics,
   or actions.

The milestone closed them in dependency order (2 mechanical, then 2
structural): output layer → spec single-sourcing → instances identity →
control channel.

## 2. What each gap became

### Output layer (gap 3)

`Response` gained a `#[serde(other)] Unknown` fallback (decoding can never
fail on growth); one `unwrap_response` core with tiny `expect_*` wrappers
replaced the per-command matches; the `Out` sink (`threepm-cli/src/output.rs`)
is the single impure output boundary — global `--json` emits the wire types
on stdout (NDJSON for `logs`), chrome and errors go to stderr, exit codes are
stable. **Adding a command's output support is one `Serialize` payload + one
`out.json()` call** (playbook in CLAUDE.md).

### Spec single-sourcing (gap 4)

`SpecPatch` (`threepm-core/src/spec.rs`) is the one description of what
users can configure: CLI flags and ecosystem files both compile down to it;
`apply()` folds it over defaults with validation (typo'd `kill_signal`
fails fast with a did-you-mean). Drift is compiler/test-enforced: a new
`StartSpec` field breaks `apply()`; the new patch field trips the
`known_fields_match_patch` test. **A new per-app field is one struct field +
one mapping line, and TOML support is automatic.** `SpecPatch` is also
serde-able by design — it is the future partial-update primitive
(`restart --update-env`, `3pm set`).

### Instances identity (gap 1)

An app is a name owning N instances; each instance is its own ProcId row
with its own policy state (supervision — backoff, crash-loop, memory guard —
is per-instance by construction; **the state machine was not modified**).
`supervisor::reconcile_app` is the one engine: `start` refreshes the spec
and reconciles the count, `3pm scale` reconciles the count without touching
survivors. Instance 0 keeps the historical unsuffixed log/pid names;
instances get `THREEPM_INSTANCE`/`NODE_APP_INSTANCE`. Doomed rows keep
their numbers occupied so paths never collide with a dying row's cleanup.

### Control channel (gap 2)

`ctl.sock` — same hardening as the other sockets — carries NDJSON both ways.
Children are told `THREEPM_CTL_SOCK` + `THREEPM_GEN` at spawn; `hello{id,gen}`
pins a connection to one incarnation (stale reconnects are hung up). Built on
it: `wait_ready`/`listen_timeout` (readiness gates `launching → online`;
policy Invariant W keeps stop latency bounded by `kill_timeout`) and
`shutdown_with_message` (graceful stop as a message, SIGKILL escalation
unchanged). Unknown message kinds are skipped on both sides — the channel
grows without breaking older peers. Client contract: four lines in any
language (`examples/hello-ready.py`; OV 3pm-docs-archived/architecture §4.3b).

## 3. The seams (where new things plug in)

| You want to add… | Where it goes | Cost |
|---|---|---|
| A CLI command / RPC | clap variant → handler → `Request` variant (+`expect_*` if a new shape) → `Command`/`Outcome` variant in `threepm-daemon/src/domain/` → `handle_rpc` arm (it matches on `Command`) | small; playbook in CLAUDE.md |
| A per-app config knob | `StartSpec` + `SpecPatch` (+ optional flag) | one field + one line; TOML free |
| A lifecycle behavior | new `PolicyInput`/`Action` in the pure machine + daemon executor arm + table/model tests | contained; keep decisions out of I/O code |
| A restart trigger (cron; watch is done this way, `watcher.rs`) | a daemon-side task producing existing `RestartRequested` inputs, reconciled from the rows by `derived::DerivedSet<K>` (`cron.rs` and `watcher.rs` share it) | additive; no policy change |
| A child↔daemon message | `ControlIn`/`ControlOut` variant (lenient decode on both sides) | additive |
| A bus event kind | `Event` variant (`decode_lenient` skips it on old clients) | additive |
| An embedded client (GUI, agent) | `threepm-protocol::client` with an injected launcher | already supported |

## 4. Remaining pm2 surface, graded

**Cheap now (additive on existing seams):** `cron_restart` (copy the
watch-mode shape: a task → `RestartRequested`), `env_<name>` blocks +
`--env` + `--update-env`, `stop_exit_codes`, `reset` (counters),
`sendSignal`, `--sort`/`--mini-list`,
monit/dashboard TUI (renders `ProcessRecord`s + the event bus), `jlist`
(already: `ls --json`), `report` (already: `internals`).

**Send-to-stdin: done by `3pm attach` (2026-09).** An app started with
`tty = true` runs on a pseudo-terminal the daemon holds;
`printf 'cmd\n' | 3pm attach <app>` (pipe mode) writes stdin to it and
prints what the app answers, and on a terminal `3pm attach` is the
interactive console (pm2's `attach`/`send`). An app on pipes keeps a
null stdin: a terminal is opt-in because it changes the app (one
output stream, `isatty` true, killed with a crashing daemon). The seam
is a request that takes its connection over (`Request::takes_connection`,
`RpcHandler::takeover`) and then speaks the live framing both ways —
the shape a future attach through the fleet link reuses.

**Done as modules (2026-09):** `serve` is the first bundled module
(`modules/serve`, its own workspace and lockfile — `docs/modules.md`).
The module system itself (`install`/`uninstall`/`module list`,
`3pm <module> …` dispatch, `spec.module` provenance shielding module
apps from `all`) lives in `threepm-cli/src/modules/` plus one daemon
seam (`resolve_mutation_target`); a `3pm set/get` config store for
modules is the natural next step on the `SpecPatch` primitive.

**Next structural feature — zero-downtime `reload`:** both prerequisites now
exist (N instances per app + ready signal). Shape: per instance, spawn the
replacement, wait for its `ready`, then retire the old one — a policy
vocabulary extension (an overlap state), not a remodel.

**On the control channel:** application metrics and events are in
(`metrics`/`event` lines, `Request::AppMetrics`, `Event::App` — one
connection per process of the app, keyed by `source`). Still open on the
same socket: remote actions (`3pm trigger` → `action` messages; the
per-source connections and peer pids are the groundwork), a Node shim
exposing pm2's `process.send('ready')` API.

**Out-of-process helpers:** the host metrics agent (`hostagent/`) is the
template — same binary, hidden role, `ReadyPipe` handshake, flock
singleton, daemon-side respawn loop, `RpcHandler` on its own socket. A
future collector that must not share the supervisor's fate goes the
same way.

**Plugin territory (keep out of core):** deploy/vizion git tooling,
PM2+/cloud link agent — both candidates for modules now. (Log rotation
used to be listed here; it moved into the core once the shared-sink
design made it a byte counter and a rename under an existing lock — see
OV `3pm-docs-archived/architecture` §6.4. The module system was listed here too until it
collapsed to a manifest, a copy and an external-subcommand dispatch.)

**Node/V8-specific (not applicable to a language-agnostic manager):**
`inspect`, V8/heap profiling, `--trace`, Node cluster fd-sharing (covered
instead by per-instance env + SO_REUSEPORT-style patterns).

## 5. Ground rules that keep this true

- Lifecycle decisions stay in the pure machine; the daemon only translates
  I/O into inputs and executes actions.
- Wire vocabularies (requests, responses, events, control messages, error
  codes) only grow — every decode path tolerates unknowns.
- The spec has one source of truth; validation happens at construction.
- Never assume name ↔ one process; never trust a message without its gen.
- Every feature lands with tests at the layer that owns it (table/property
  for policy, paused-time component for the actor, e2e for the user surface).
