The nmux protocol synchronizes backend-owned terminal state. It is not a raw PTY byte stream and it is not an RPC surface for terminal commands.
The current schema lives at schema/nmux.fbs.
The current schema has grown beyond the original M0 skeleton. It defines these state-sync envelope bodies:
Envelopefor versioning, sequencing, acknowledgements, and body dispatch.WorkspaceTreeSnapshotfor sessions, tabs, panes, split layout, pane sizes, and resize policy.SessionInventorySnapshotfor daemon-scoped named session discovery.PaneSurfaceSnapshotfor a full visible or alternate screen surface.PaneSurfacePatchfor row, cursor, or terminal-mode updates against a known surface version.- Decoded workspace, surface, attach-status, and scrollback state rejects missing or empty session, tab, and pane IDs instead of creating empty client cache keys.
ScrollbackFetchandScrollbackChunkfor pane-scoped history ranges.InputEventfor key, raw byte, paste, focus, and mouse input from an actor to a pane.ResizeIntentfor client-originated size requests.ControlCommandfor workspace control requests: pane split, tab creation, tab switch/close, and session kill.PresenceUpdatefor actor join/leave-style presence events. Decoders require non-empty actor, user, display, and present focused-pane IDs.AttachRequestfor actor identity, attach mode, focused pane, and known pane surface versions at attach time.Envelope.session_idis an optional attach target; absent means the daemon default/current session, while a present non-empty value must identify the daemon-owned session or attach returnsErrorCode::SessionNotFound. Decoded attach requests reject missing or empty identity strings and known-surface pane IDs instead of substituting local defaults.AttachStatusfor the daemon-selected pane and whether a surface frame follows the attach response.Errorfor protocol-level failures.
Permissions, sandbox hosts, adapters, and image-specific payloads remain outside the current envelope. They should be added as new envelope bodies when their object model is clear. Future hyperlink identity, image, richer damage, command lifecycle, and physical-key/text-event work is tracked in Future Protocol Tracks; those tracks need ADRs and compatibility plans before schema fields are added.
nmux daemon owns terminal state for each pane. Clients attach with the versions they know, receive snapshots or patches, render that state, and send input or control intents back to the daemon.
This keeps frontend behavior consistent across native, web, mobile, and automation clients. It also makes reconnect a protocol feature: a client can resume from its last known object versions instead of replaying terminal output.
Pane surfaces and scrollback chunks are encoded as rows of runs:
SurfaceRowandRowUpdateidentify rows by index and include both a stable text-onlydirty_hash, arow_state_hashcovering runs and row metadata, the backend rowdirtyflag, and Kitty virtual placeholder presence.ScrollbackRowidentifies history rows by absolute scrollback line and includes both a stable text-onlydirty_hash, arow_state_hashcovering runs and row metadata, the backend rowdirtyflag, and Kitty virtual placeholder presence.CellRunstores UTF-8 text, per-cell widths, a style table reference, flags, optional hyperlink reference, and per-run semantic content. Bit 0 inCellRun.flagsmeans backend hyperlink presence for the run. Nonzerohyperlink_idvalues resolve through theHyperlinktable carried by the samePaneSurfaceSnapshotorScrollbackChunk; the current extractor still leaves IDs zero until URI identity is wired.cell_widthsis one byte per rendered cell in the run, so wide characters carry a width of 2 at their rendered cell position.Hyperlinkstores table-backed hyperlink identity for full snapshots and scrollback chunks: numeric ID, target URI, optional OSC 8 identifier, and optional raw parameter string.PaneSurfacePatchdoes not carry hyperlink table diffs yet, so patches must not introduce references to unknown IDs.Styleis a compact table referenced by run IDs. FullPaneSurfaceSnapshotobjects andScrollbackChunkobjects carry the style table needed by their rows.fg_rgba,bg_rgba, andunderline_rgbause0xRRGGBBAApacking; zero means the backend did not publish an explicit color for that field.Style.flagscurrently reserves bits 0-7 for bold, italic, faint, blink, inverse, invisible, strikethrough, and overline, and bits 8-12 for single, double, curly, dotted, and dashed underline. Unknown bits must be preserved by clients that cache or forward style tables.CursorStatestores cursor row, column, visibility, shape, and blinking.TerminalMetadataStatestores pane terminal title and working-directory metadata. The libghostty-vt path tracks OSC 7 byte sequences and populates the working-directory value carried by snapshots, patches, and cached client state.TerminalColorStatestores backend-observed default foreground/background, optional explicit cursor color, and the active palette. Snapshots and scrollback chunks carry full palettes. Color-only patches may carry a palette diff usingpalette_diff_startandpalette_diff_rgbaagainst the patch base version instead of a fullpalette_rgbavector. Clients reject palette diffs on snapshots, scrollback chunks, and non-color patches because those objects must be self-contained or row/state compatible. Row, style-table, or surface-kind changes that also affect colors still require a full surface refresh. All terminal color fields use the same0xRRGGBBAApacking as style colors;cursor_rgba_setdistinguishes an unset cursor color from an explicit transparent/zero value.RowSemanticPromptstores OSC 133 prompt-line metadata on surface rows, row updates, and scrollback rows.CellSemanticContentstores the backend-observed OSC 133 content class for each run: output, input, or prompt. Broader shell command IDs, ranges, lifecycle, and exit metadata remain withheld until that object model is explicit.TerminalModeStatestores terminal modes that clients need for input and rendering decisions: bracketed paste, mouse tracking, focus reporting, application keypad, application cursor, origin, and wraparound. Mouse tracking keeps a compatibility boolean and also carries the backend-observed tracking mode (None,X10,Normal,Button, orAny) plus mouse encoding format (X10,Utf8,Sgr,Urxvt, orSgrPixels).
This avoids freezing a simplistic per-cell ABI before the project has enough implementation feedback about graphemes, combining marks, double-width characters, terminal modes, hyperlinks, and image protocols.
Clients should maintain a pane-surface render state keyed by pane ID and version. A snapshot initializes the local surface buffer and style table, and a patch is applied only when its base_version matches the client's current version. Clients reject unknown terminal-state enum values in snapshots and patches, including SurfaceKind, PatchKind, cursor shape, mouse tracking mode, and mouse encoding format, instead of treating them as compatible fallback states. Control-plane frames likewise reject unknown PaneKind, SplitAxis, AttachMode, PresenceKind, AttachSurfaceState, ResizePolicy, ResizeReason, and ErrorCode values instead of coercing them to defaults. Decoded error frames require a non-empty message and reject an empty optional pane ID. Row patches are applied by encoded row index, not by vector order. Cursor-only patches update cursor state without row updates.
Attach responses send WorkspaceTreeSnapshot, PresenceUpdate, and then
AttachStatus. AttachStatus.pane_id is the daemon-selected attached pane.
AttachStatus.surface_state is Current when the client already has the
current surface and no surface frame follows; otherwise it is Snapshot or
Patch and the next frame is the matching pane surface object. Clients should
use the status pane ID, not a guessed default or the workspace root pane, for
current-surface cache lookup and post-attach input, resize, and scrollback
requests. Clients render Current only when their cached surface for
AttachStatus.pane_id exactly matches AttachStatus.surface_version;
otherwise they reject the response instead of showing stale or missing state.
Current must not include a surface frame. Snapshot and Patch must be
followed by the matching surface frame type, and that frame must carry the same
pane ID as AttachStatus.pane_id; clients reject mismatches instead of applying
state to the wrong pane or recovering from the wrong object kind. If the daemon
cannot resolve its active tab or active pane, it sends a PaneNotFound Error
instead of publishing an AttachStatus for a guessed pane.
Cursor-only, mode-only, and color-only patches can also update
TerminalMetadataState without row updates. Metadata-only updates are therefore
ordinary PatchKind::CursorOnly patches with no row payload; there is no
separate metadata-only patch kind. Mode-only patches update TerminalModeState
without row updates, and color-only patches update TerminalColorState without
row updates. If a color-only patch carries a palette diff, clients apply it to
their cached palette for the matching base_version; the diff is not an
absolute palette. These are versioned surface changes because future input
encoding, renderer behavior, and pane chrome can depend on terminal state even
when visible text does not change.
PaneSurfacePatch intentionally does not carry a style table or hyperlink table. If the daemon's style table changes, if a row update would reference a new hyperlink ID that is absent from the client's cached table, if color changes are coupled to row/style changes, or if terminal state changes in a way the current patch schema cannot express, the daemon must use PatchKind::FullRefreshRequired. FullRefreshRequired is a recovery marker, not a row patch, and clients reject it when it carries row updates. During attach, AttachStatus.surface_state = Snapshot is the recovery signal and the daemon immediately follows it with a full PaneSurfaceSnapshot. Clients must reject unsupported patch kinds instead of treating them as cursor-only updates. They must not recover by replaying raw PTY bytes.
Scrollback is a separate versioned object. Clients request ranges with
ScrollbackFetch; the daemon replies with ScrollbackChunk rows and the
corresponding style and hyperlink tables for that chunk.
ScrollbackFetch.start_line, ScrollbackChunk.start_line, and
ScrollbackRow.line are 1-based public line numbers, so line 1 is the oldest
retained row in the chunk's pane history. Clients and daemons reject zero
scrollback start lines, zero fetch counts, and zero row line numbers instead of
normalizing them to the oldest row. Non-empty chunks must carry a contiguous
public range: row i has line start_line + i, and no row line may exceed
total_lines.
Out-of-range fetches may return a valid zero-row ScrollbackChunk; clients can
render that empty result, but should not persist it as a cached range/version
precondition because the original requested count is not represented in the
chunk rows.
ScrollbackFetch.known_scrollback_version = 0 means the client is not asserting
a cached scrollback version. Nonzero known versions must match the daemon's
current pane scrollback version; mismatches return ErrorCode::StaleVersion
instead of serving a range from a different history version.
Clients do not directly resize PTYs. They send ResizeIntent with desired
columns, rows, actor, pane, and reason. ResizeReason::UserCommand represents
an explicit user request such as live --cols/--rows.
ResizeReason::FrontendViewport represents automatic frontend viewport changes
such as interactive SIGWINCH handling. The daemon applies actor permissions
and pane resize policy, then later publishes committed size through workspace
or pane state. Read-only actors receive PermissionDenied if they send a live
resize intent.
The policy is part of PaneNode so a pane can be fixed-size,
leader-controlled, active-client-controlled, or manual. Manual resize policy
rejects automatic frontend viewport changes while still allowing explicit
user-command resize intents.
PresenceUpdate carries actor presence and side-effect-free daemon liveness.
Visible client membership uses PresenceKind::Joined, Updated, and Left.
PresenceKind::HealthProbe is a short-lived client request that asks whether
the daemon can serve the selected session target. The daemon answers with
PresenceKind::Heartbeat when the target is usable, or an Error frame when
the daemon is reachable but cannot serve that target.
Health probes and heartbeats do not add actors to visible presence, do not change focus, do not participate in frontend resize policy, and do not require surface or scrollback delivery. Bare default mode uses this probe before reusing the shared default socket; resize intents are never used as liveness checks.
Ping and Pong are side-effect-free live control frames for measuring
client/server round-trip time. Clients send Ping.actor_id and a client-local
monotonic ping_seq; daemons answer immediately with Pong echoing both
fields. Ping/pong frames do not touch panes, process hosts, terminal engines,
surface caches, visible presence, or resize policy. ADR 0040 records the RTT
metric boundary and why input-to-display timing is tracked separately.
Clients that set AttachRequest.subscribe_client_inventory receive a live
connection inventory stream. The daemon sends a ClientInventorySnapshot
followed by ClientInventoryPatch frames with joined, updated, and left
connection IDs. Each ClientConnection is a daemon-assigned connection, not
just an actor: it carries actor/user/display identity, hostname, client kind,
attach mode, focused pane, connected time, last-seen time, and optional
last-input time. Inventory frames are control-plane state and do not change
presence, pane focus, resize policy, or terminal surfaces. ADR 0041 records the
snapshot/patch subscription model.
InputEvent is still a client-to-daemon intent, not authoritative terminal state. Text-oriented commands can use InputKind.Key with KeyInput.text_utf8; named-key commands can use InputKind.Key with KeyInput.key_name, currently for keypad Enter/digits, arrow keys, Enter, Tab, Backspace, Escape, Insert, Delete, Home, End, PageUp/PageDown, and F1-F12. The local daemon asks the live pane terminal engine to encode named keys from daemon-owned terminal state; the interim engine preserves existing unmodified keypad/application-cursor behavior, while the libghostty-vt engine uses its key encoder and preserves KeyInput.modifiers. Decoded client input requires the payload table matching its InputKind (KeyInput, RawInput, PasteInput, FocusInput, or MouseInput) instead of treating missing tables as empty input. Public CLI named-key and mouse modifiers use the low four protocol bits: shift=1, ctrl=2, alt=4, and super=8; decoded client input rejects unsupported modifier bits outside that mask. Byte-oriented live clients should use InputKind.RawBytes with RawInput.bytes so control bytes and non-UTF-8 input do not get lossy string conversion before they reach the process host. Paste-oriented commands use InputKind.Paste with PasteInput.text_utf8; the local daemon rejects embedded bracketed-paste terminators, ignores the historical client-provided PasteInput.bracketed preference, and wraps the paste in bracketed-paste delimiters only when daemon-owned pane mode reports bracketed paste enabled. Focus commands use InputKind.Focus with FocusInput.focused; the local daemon forwards focus gained/lost bytes only when the daemon-owned pane mode reports focus reporting enabled and otherwise returns a protocol Error. Mouse commands use InputKind.Mouse with zero-based cell coordinates, optional pixel coordinates, button, modifiers, and action; clients reject unknown mouse button/action enum values instead of coercing them to defaults. Attach requests also reject unknown attach modes rather than treating them as read-only. The local daemon gates mouse input by daemon-owned pane size and mouse tracking mode (None, X10, Normal, Button, or Any) before asking the live pane terminal engine to encode bytes from the current terminal mode and mouse format. Pixel coordinates are used for MouseFormat::SgrPixels when present; otherwise the event is encoded at the referenced cell origin. Local clients maintain monotonic Envelope.seq values across post-attach client frames and monotonic InputEvent.input_seq values across input events on the same connection. ADR 0017 records the input-mode policy and the remaining physical-key/text-event boundary.
When a structured input event cannot be encoded or safely forwarded, or a live
control intent such as resize cannot be applied by the process host, the daemon
sends an Error frame instead of terminating the connection with an opaque
socket close. Error.pane_id identifies the pane-scoped request that failed
when the daemon can attribute the failure to a pane. Error.input_seq carries
the originating InputEvent.input_seq for input failures and remains zero for
non-input failures. The local CLI reports the frame with the server-provided
reason. Pane-scoped client input, resize, and scrollback-fetch frames must carry
non-empty pane_id and actor_id strings; malformed frames are rejected during
decode instead of being treated as requests for an empty pane. Pane-scoped client
intents for unknown panes, missing active-tab or active-pane metadata during
attach, and unauthorized control intents return protocol Error frames instead
of hanging, silently omitting a response, or falling through to process host
behavior.
ControlCommand carries actor_id, a client-local command_seq, the command
kind, and optional target fields. Pane split uses pane_id and split_axis;
tab new uses tab_id and optional title; tab switch/close use tab_id;
session new uses session_id and optional title; session kill uses
session_id; session list has no target fields and returns
SessionInventorySnapshot. Successful mutating commands return WorkspaceTreeSnapshot.
SessionKill also asks the daemon to stop the targeted session after sending
that acknowledgement. A non-empty session_id must match the daemon-owned
session or the daemon returns ErrorCode::SessionNotFound.
Validate the schema with:
nix develop . -c just check-schemaThe check-schema target runs flatc against schema/nmux.fbs. Schema edits should stay append-friendly unless an ADR explicitly changes the compatibility posture.
Generate Rust protocol bindings with:
nix develop . -c just generate-schemaThe Rust core consumes the generated bindings through the nmux-proto crate.