NZM is a local Rust control plane for running coding agents in named Zellij sessions. It generates KDL layouts, tracks sessions and panes in SQLite, exposes machine-readable JSON, serves a REST/SSE API, and includes a Ratatui dashboard.
Install Rust 1.85 or newer, Zellij 0.40 or newer, and whichever agent CLIs you intend to launch. NZM currently recognizes Claude Code, Codex, Gemini, and shell panes.
cargo build --release
cargo test --workspaceThe binary is written to target/release/nzm.
# Create a session with two Claude Code panes and one Codex pane
nzm spawn example --cc 2 --cod 1
# Inspect and manage sessions
nzm list
nzm status example
nzm attach example
nzm read example --agent cc_1
nzm kill example
# Generate a reusable layout
nzm layout generate --cc 2 --cod 1 --gmi 0
# Machine-readable state
nzm --robot-status
nzm --robot-snapshot
nzm robot capabilities
# Reconcile the registry with live zellij state (e.g. after a manual kill)
nzm sync
# Interactive dashboard and HTTP API
nzm dashboard
nzm serve --port 7337
# Run the API as a loopback-only background service (systemd user unit)
nzm service install
nzm service status
nzm service logs
nzm service uninstall
# One-shot, read-only activity evidence for registered agents (JSON)
nzm activity snapshotRun nzm --help or nzm <command> --help for the exact interface supported by your build.
NZM reads ~/.config/nzm/config.toml. Defaults work without a configuration file. State is stored under ~/.local/share/nzm/, and generated layouts are stored under ~/.config/nzm/layouts/.
Example:
database_path = "/home/me/.local/share/nzm/nzm.db"
layout_dir = "/home/me/.config/nzm/layouts"
[defaults]
cc = 1
cod = 0
gmi = 0Start the server with nzm serve --port 7337. The current API provides health, session, agent, checkpoint, and SSE endpoints under /health, /api/v1, and /events.
For compatibility the CLI default binds all interfaces (serve --host defaults to 0.0.0.0). The installed nzm.service unit always pins loopback (--host 127.0.0.1), so an always-on background service never exposes the API to the network. If the chosen address is already in use (for example by the installed service on 127.0.0.1:7337), nzm serve reports a bind error and suggests checking nzm service status or picking another --host/--port.
While nzm serve is running, NZM reconciles SQLite state against live Zellij sessions once per second and broadcasts committed session and agent status transitions over SSE. Pane identity remains available through the session and agent REST responses; there is no pane-level or exit-code event stream. The registry, not the event feed, is the authoritative snapshot.
Reconciliation is measured: every completed tick emits exactly one debug-level record carrying duration_ms, sessions_checked, session_changes, agent_changes, and result (ok | error | join_error); failed ticks carry zero counts because no report was committed. Passes are serialized — a tick never starts before the previous one finishes — and missed ticks are skipped rather than caught up. Because the cadence is a fixed one second, these debug records stay quiet under the default info logging; observe them with RUST_LOG=debug nzm serve, or read them from the user journal for the installed unit (see below). The one-second cadence is kept unless live measurement shows a p95 pass time above 250 ms or any pass at one second with realistic local session counts — only measured need may change the interval.
nzm sync remains available as a manual repair command when the server is not running (for example, after a Zellij session is killed directly).
nzm service manages a loopback-only systemd user unit named nzm.service that keeps nzm serve running after the launching shell exits:
nzm service installresolves the current executable and thezellijbinary up front (failing before any write when either cannot be resolved), writes the unit to~/.config/systemd/user/nzm.service(honoring$XDG_CONFIG_HOME), probes the current binary withserve --helpto confirm it advertises--host, then runssystemctl --user daemon-reloadandsystemctl --user enable --now nzm.service. The generated unit isType=simplewithRestart=on-failureandRestartSec=5, executesserve --host 127.0.0.1 --port 7337, carries an explicitEnvironment=PATHbuilt from the resolved directories of the current executable andzellijplus standard system directories, and isWantedBy=default.target. Installing an identical unit skips rewriting the file but still checks linger. A divergent existing unit is refused, never overwritten.- Without linger, systemd tears down the user manager on logout, so
nzm.servicestops. If linger is off (or cannot be queried), install printsloginctl enable-lingerand still succeeds.nzm service install --lingerruns that command for the current user; polkit denial is a warning, not an install failure. Uninstall never runsloginctl disable-linger. nzm service statusandnzm service logsdelegate tosystemctl --user status nzm.serviceandjournalctl --user -u nzm.service(no pager), forwarding output verbatim and preserving the delegated exit status. Service logs land in the user journal, not in files; the debug-level tracking records described above are part of that journal when debug logging is enabled (systemctl --user set-environment RUST_LOG=nzm=debug, then restart the unit).nzm service uninstallstops and disables the unit withsystemctl --user disable --now, removes only the unit file, and reloads user systemd; a missing unit file is tolerated. The registry database, generated layouts, and Zellij sessions are never touched, so uninstalling is reversible with anotherinstall. Linger is left as the operator set it.
The service runs without root privileges and only reaches 127.0.0.1:7337.
nzm activity snapshot is one-shot, read-only activity evidence for registered agents, designed for external consumers:
- Stateless and pure: nothing is stored, no agent status is mutated, and no screen text is parsed. NZM reports only what it observed; interpretations such as quiet or idle are left to the consumer.
- For every registered agent with a bound pane, the exact bytes that zellij's
dump-screenemits are hashed with deterministic FNV-1a (64-bit, standard offset basis and prime) and reported as 16 lowercase hex digits. The hash is defined over the raw bytes — no UTF-8 lossy conversion — so an identical screen always yields an identical hash. - A missing bound pane or a failed dump is reported with
probe=unknown,signal=probe_failed, a nullscreen_hash, and a detail string. Agents the registry marksStoppedorErrorare reported withprobe=unknown,signal=lifecycle, and a nullscreen_hash: registry lifecycle truth overrides screen probing entirely. - Output is exactly one JSON document on stdout and exit status 0, even for zero registered agents. Hard failures (registry open/query, serialization) exit nonzero with empty stdout; diagnostics go to stderr. The schema is frozen as version 1: the envelope carries
version,generated_at, and anagentsarray; each agent carriessubject(the stable join key<session>:<label>— pane ids never enter subjects),session,agent_label,agent_type,probe(observed|unknown),signal(screen_observed|lifecycle|probe_failed),observed_at,screen_hash(or null),zellij_pane_id(or null),lifecycle(running|stopped|error), anddetail(or null). Frozen consumers reject unknown fields and values outside the documented enums.
notifier-ng consumes NZM evidence through its own adapter and a managed user timer; NZM itself never delivers notifications:
- The adapter invokes
nzm robot snapshotfor lifecycle andnzm activity snapshotfor evidence, validates both against the frozen contracts, joins subjects by<session>:<label>, and classifies each agent subject: sessions NZM reportsstoppedmap tostopped; agents reportederrormap toerror(needs attention); observed activity maps toactive; screen evidence unchanged across the quiet threshold maps toidlenotifications labeledquiet_only;unknown/probe_failedevidence emits no record — a failed probe never fabricates a notification. While a notifiable state persists (quiet past the threshold, stopped, error) the evidence is re-emitted on each run; delivery deduplication belongs to notifier core, so a repeated quiet never delivers twice until the screen changes — any change rearms the adapter. - The quiet threshold is adapter policy, not NZM policy:
NZM_QUIET_SECONDS(default 900) in the adapter's environment, with hysteresis state under notifier-ng's XDG state directory. First observation baselines without delivery. - Codex, Hermes, and OMP hooks remain the authoritative idle signal; NZM-backed quiet is a conservative complement, never a replacement — quiet means no observable screen change, not proof of waiting.
- The adapter runs on a one-minute user timer managed by the notifier-ng installer (dry-run/
--apply, never clobbering existing units), which prints the exactsystemctl --userenable commands rather than invoking systemctl itself. Cutover disables the legacynotifier-ng-zellij.timeronly after the NZM-backed timer has been verified from the journal; rollback re-enables that timer and removes the NZM timer units.nzm service uninstallremains safe at any point: it touches only the NZM unit.
The public CI runs formatting, Clippy with warnings denied, and workspace tests on GitHub-hosted ubuntu-latest runners. See CONTRIBUTING.md and SECURITY.md.
GNU Affero General Public License v3.0 or later. See LICENSE.