Ovvi

Rex as a second multiplexer

How a Rex backend fits behind the Multiplexer interface

Not built, and not scheduled. This page records how Ovvi can talk to Rex through the same Multiplexer interface that herdr uses, so the work is ready when it is picked up. The protocol research is in multiplexers/rex.md. The Rex app's UI is in clients/rex.md.

What Rex is

Rex (superlogical.com) is a terminal multiplexer for macOS and Linux servers by Superlogical. It has two parts:

  • Rex Server: a Go program, also the rex CLI. One server runs per user account and machine. It listens on a Unix socket (mode 0600): on macOS ~/Library/Application Support/rex/server.sock, on a Linux server it can be /run/rex/server.sock. It keeps the shells alive and runs one libghostty-vt emulator per terminal. Rex sessions do not survive a package upgrade on Linux, and likely no server restart (herdr keeps its layout across restarts).
  • Rex.app: a SwiftUI and AppKit client with its own libghostty-vt and Metal renderer.

The server speaks HTTP/1.1 on the socket. GET /llms.txt returns the whole protocol as Markdown, and GET /api/help returns it as JSON with JSON Schemas. The research used rex 0.0.1792607779-g171246, API revision 5. Revision 5 adds only server.apple.privacy_status, so a hello with api_revision: 4 still works.

How the models map

RexOvviNotes
server (host)machine, and its one Sessionone server per account and machine; sessions() returns one entry
sessionspaceordered; generated label such as crisp-forest
windowtabordered; each session has an active_window_id
layer tiledthe tab's split treesplit.direction horizontal is .right, vertical is .down; ratio is the first child's share
layer floatingnone yeta block that a Rex.app user floats would vanish from Ovvi if skipped; show it as a floating pane (Floating pane) or as a card at the end of its tab. Detached blocks, blocks from other hosts (host_id) and blocks no terminal made need the same choice
blockpane and terminalone stable id, block:<uuidv7>, so Pane.id == Pane.terminalID
clientone Ovvi app on one machinesurvives a dropped connection for 60 s

A block keeps its id when it moves between windows. Rex has no separate terminal id, and none is needed. A block never leaves its Rex session, so with this mapping a pane cannot move to another space or a new one. Rex gives no git state and no worktree groups for a space; the app's own git status run (Git status) still works from the pane's cwd.

Transport

Rex has no SSH transport. Its client endpoints are the Unix socket, HTTP(S) and Tailscale. Ovvi does not need any of them: a raw WebSocket works over the socket through nc -U, which the research tested.

  • Ovvi runs nc -U <socket> through Exec.run. On This Mac this is a local Process. On a remote machine it is the same argv inside the remote command envelope, over the relay. The relay carries only lines today, and the WebSocket frames are binary, so the relay must also carry bytes (base64 lines, as Exec.upload already sends them). The other way: a stream-local forward on the ControlMaster (ssh -S <ctl> -O forward -L <local.sock>:<socket>), with no nc.
  • The channel carries plain bytes. Ovvi speaks WebSocket (RFC 6455) on it with a small client of its own: about 150 lines, client masking, text and binary frames, ping and pong. URLSessionWebSocketTask cannot open a Unix socket.
  • rex server status --json gives the socket path (endpoint) on each machine; the path differs between macOS and Linux. rex server start starts the server. It is idempotent.
  • If nc is missing, socat or python3 can carry the same bytes.

Each machine has two kinds of WebSocket:

SocketPath and subprotocolCountCarries
control/api/control, rex.control.v1one per machineJSON hello, welcome, request, response, event
data/api/data, rex.data.terminal.v1at most one per block per clientbinary: handshake, snapshot, then raw PTY bytes

The control socket opens with a hello (api_revision: 4, client.kind: "app", client.name: "Ovvi"). The welcome returns a client.id and a reconnect_token. A request is about 0.3 ms on the server, so Ovvi needs no pacing beyond the event rules below.

One client cannot open two data streams for one block: a second HELLO gets REJECT (82 02) and then a close with 1008. Two Ovvi windows on one session (Control between windows) share one stream per block, fanned out to both windows, or open one Rex client per window.

RexClient

RexClient is the second Multiplexer conformer. It owns the control socket. It sends requests by id and gets responses out of order. It gives events to one reader.

  • Connect (in HostStore): the control socket, hello, then session.bootstrap {event_scope: "all"} and session.attach for every session. Require api_revision >= 4.
  • Snapshot: session.view {session_id} per session, in parallel. The view has every window, the tiled layout with ratios, and each window's focused_block_id. Titles, the process (cwd, program) and program status are not in the view: they cost one call per block at snapshot time (com.superlogical.terminal.title, process, program_status), then block events keep them up to date. process.foreground.cwd is the real cwd, with no OSC 7 needed.
  • Create calls send focus: false (session.new_window defaults to true). A new tab is session.new_window {session_id, layout: {block: {flavor, options: {cwd}}}, focus: false}; a new space is session.create {initial_windows: [...]}, then session.attach (create does not attach).

Every Multiplexer member, with its Rex call, is in the research's method-by-method table: 24 of its 34 rows (32 members; a move has three rows) map directly. 6 need adapter code: one session per server, visible text cut from format, divider drags and ratios found in the tree, a move to a new tab built from several calls, and key names turned into key objects. 4 cannot work: deleteSession, mark, scroll and a move to a new space. The adapter's own work is listed there too (Verdict). Stream fan-out is in Transport, stale owners in Ownership.

Rex has no absolute viewport offset (the find bar scrolls the local surface instead) and no visible-only text: the hover card's lines come from com.superlogical.terminal.format {format: "text", trim: true, unwrap: true} (with scrollback, up to 1 MiB), last rows lines.

The flavor for a new shell is com.superlogical.terminal.shell.

Events

One control socket carries all events. Each event has a seq that starts at 1 and has no gaps. A response carries the seq of the last event before it. So Ovvi applies the response, then only events with a higher seq.

  • session_view_changed {revision}: read session.view again for that session, but only if the revision is higher than the one Ovvi holds. These events can arrive out of order.
  • session_created: attach the new session, then read its view. session_destroyed, session_moved and session_label_changed change the space list.
  • block_event with program_status_changed or program_status_removed: update the agent status in place.
  • block_event with process_changed or pwd_changed: update the pane's program and cwd.
  • A close with 1008 (event backlog exceeded), or a dropped socket: connect again, send reconnect_token, bootstrap and read every view again. Events in the gap are lost.

herdr's events carry no state, so Ovvi reads a snapshot after each one (D5). Rex's revisions let Ovvi skip a read when the view did not change.

RexTerminalStream

Rex streams raw PTY bytes, not a redrawn screen. Ovvi's libghostty surface is then the second copy of the terminal: the server keeps the first.

Open a stream

  1. Call data.connect {session_id, block_id, data_ref_id: "terminal", subprotocol: "rex.data.terminal.v1"} on the control socket. It returns a one-time ticket that is good for 60 s.
  2. Open the data socket on a new nc -U channel. Within 5 s, send the binary HELLO: 01 and the 16 ticket bytes.
  3. Read the 32-byte WELCOME: 81, a 16-byte reconnect token, 3 zero bytes, a u32 keep time in ms and a u64 nonce. A REJECT is 82 and a reason byte.
  4. Read the rest as one byte stream. Message boundaries carry no meaning.

The snapshot

The stream starts with a GHOSTSNP snapshot: libghostty-vt's own binary format, with the screen, the modes and the scrollback. It has a 10-byte header ("GHOSTSNP" and a u16 version), then records of [u16 tag][u32 len][u32 crc32c][payload]. The FINISH record (tag 6) ends it.

libghostty-spm, the library Ovvi uses, has no snapshot decoder. So:

  • First version: walk the record framing to the FINISH record (about 20 lines) and drop the snapshot. Seed the screen with com.superlogical.terminal.format {format: "vt"} and every extra_* flag on. Then apply the live bytes. Output in the few ms between the snapshot and the format reply is drawn twice. Shells and agent TUIs redraw, so this is harmless. format fails with too_large above 1 MiB, so the app then sends trim or starts with no history.
  • Later: decode the snapshot with libghostty-vt (ghostty_snapshot_decoder_*), format it as VT and feed that to the surface. This is exact, but it adds a second binary dependency. The format is "v1, work in progress", and it must match the libghostty version in Rex.

Live bytes and Rex messages

After the snapshot come the program's raw bytes. Rex puts its own messages in the stream as APC sequences: ESC _ rex1;<nonce>;<json> ESC \. The types are resize, theme and child_exited. A resize and usually a theme come right after the snapshot.

  • Strip each APC whose nonce matches the WELCOME nonce. Keep a carry buffer, because an APC can span two messages. A theme APC is about 3 KB.
  • Pass an APC with another nonce through as output: a program printed it.
  • Resize the local emulator at the resize APC, not when the card changes size. No full frame follows a resize. The program redraws after SIGWINCH.
  • child_exited ends the program. The data socket then closes with 4000 (block closed) when the block closes.

Size, scrollback and queries

  • The owner sets the PTY size with com.superlogical.terminal.resize and the colors with set_theme. A non-owner's resize is only recorded as "desired".
  • A watcher gets bytes for the owner's grid. Its surface grid must stay at the APC size, and the card clips or scales it.
  • The scrollback is Ovvi's own: the snapshot's history plus live output. The surface keeps a real scrollback limit, and the wheel scrolls it locally.
  • The server answers terminal queries (DA, OSC 10 and 11), so the surface must not answer them again. Under herdr this never comes up: herdr's frames are a redrawn screen, with no queries in them. Rex's raw bytes carry the queries, so the surface needs a way to drop its own answers (uHerdr's suppressesTerminalResponses patch to libghostty-spm is one).

Input

All input goes on the control socket. Data sent on the data socket after the WELCOME is ignored.

  • Keys: client.key {session_id, block_id, key}. The key object has the same fields as libghostty's ghostty_input_key_s (key, mods, consumed_mods, text, unshifted_codepoint, composing, action). So the surface forwards every key event as it is, and the server encodes it for the program's modes. The split between text and sendKeys goes away.
  • Typed text: client.action {action: "com.superlogical.terminal.write", args: {data}}. This keeps text in order with keys. A direct terminal.write is not ordered.
  • Paste: block.event {type: "paste"}. The server adds bracketed paste when the program asks for it.
  • Mouse and wheel: block.event {type: "mouse"} and {type: "wheel"}, only while the program has mouse reporting on. Otherwise the local surface scrolls and selects. Outside mouse mode, the server scrolls and selects on its own shared copy, which Ovvi does not use.
  • Focus: block.event {type: "focus"}, only with mode 1004 on.

End and resume

EventThe app
close 4000drops the card; waits for the view
socket dropssends RESUME (02, the latest token, a u64 count of stream bytes read) within 60 s; output from the gap arrives
RESUME failsopens a new stream with a new ticket
close 1000 from the appthe app stopped showing the card; it then calls session.disown_block if it owned the block

Agent status

Rex agents report status with OSC 7501, the Program Status Protocol that Rex designed. The server parses it and keeps records per block: state (idle, working, done, blocked, error), kind (permission, question, auth), progress, app, title and msg.

Ovvi reads these records through the API, with com.superlogical.terminal.program_status and the program_status_changed and program_status_removed events. It never parses OSC 7501 in its own surfaces. This keeps D55: status comes only from the multiplexer's API, and every device agrees.

  • A block's status is its strongest record: blocked, then working, then done or error, then idle. error shows as done with the word "failed".
  • Space and tab status fold their blocks, as herdr's do.
  • Rex has no completion_seq. Ovvi uses a done record's updated_at as the finish marker. "Seen" stays in the app, keyed by block, record id and updated_at.
  • Codex sends OSC 7501 itself. An agent that does not (Claude Code, for example) has no record. Ovvi then knows the agent only by process.foreground.name, with no status.

Ownership

A Rex block has at most one owner client. The owner sets the PTY size and theme. Anyone attached can stream it, type into it and close it. This is close to herdr's control and observe, and Ovvi's control rule (D8) maps onto it:

Ovvi's control rule (D8)Rex
open without takeoversession.own_block {block_ids, mode: "request"}; if the owner is not in client.list, mode: "take"
refused: watch, show Take Backthe result's owner is not Ovvi's client id
first key, paste or click, or Take Backsession.own_block {mode: "take"}
"taken over"block_owner_changed {previous: <me>, reason: "take"}
iPhone and iPad, if built: watch only (iPhone and iPad)no own_block call; stream only
card no longer shownclose the data socket, then session.disown_block

A block that session.create made from a short-lived client keeps its dead creator as owner, so request never succeeds for it. So the app compares the result's owner with client.list and takes the block when the owner is not a live client.

Two differences matter:

  • A watcher can still type in Rex. Ovvi keeps its own rule: a key press in a watching card takes control first, so the PTY size follows the device that types.
  • When the owner leaves, Rex hands the block to the next claimant in line and resizes it to that client's last desired size. herdr does not. Ovvi shows the new state from the block_owner_changed event and does not take back by itself.

Rex.app claims the blocks that it shows. If Rex.app and Ovvi show the same block, ownership moves between them on each claim. This is accepted.

Focus

Focus is shared server state in Rex: each session has an active window, and each window has a focused_block_id. The rules of D13 apply:

  • Every create call sends focus: false. session.new_window defaults to focus: true.
  • Ovvi never calls session.focus_*.
  • session.zoom_block moves focus to its block unless the call names the window_id. Zoom stays the app's own (Zoom).
  • session.swap_blocks and session.move_block can move focus with the block. Ovvi calls them only after a drag, as with pane.swap.
  • Ovvi reads focused_block_id once, for the first selection, as it does with herdr.

Changes to HerdrKit

Today a second backend cannot build the app's model types from outside HerdrKit. These changes come first, each as small as it can be:

  1. Public memberwise inits for Snapshot, Workspace, Tab, Pane, Agent and SplitNode. They have only decoding inits now (Tab also has one for the stand-in tab), and Snapshot.trees has an internal setter.
  2. TerminalStream as a struct of closures (events, input, resize, scroll, mouse, close). Today it parses herdr's NDJSON from a Channel and its init is internal.
  3. A byte Channel. Today Channel gives stdout as UTF-8 lines. The WebSocket frames need raw bytes in and out.
  4. A neutral error. HerdrError is the error type of Update.error and HostStore.State today. Its messages name herdr, such as "herdr did not answer". It becomes an error with a fix string that the backend gives.
  5. One function picks the backend per machine. Today the app makes a HerdrClient in a few places (HostStore, the Open Session panel, window restore).
  6. A lookup for rex like the one for herdr. Exec.herdr() asks the login shell for herdr's absolute path (D38). A Rex client needs the same for rex, or for nc and the socket path from rex server status --json.

The terminal path in PaneTerminal then branches on the backend: a real scrollback, all keys through client.key, and wheel events sent only in mouse mode.

Testing

Tests never touch the user's Rex server. Each e2e run starts an isolated server on its own socket, with its own state folder and an empty config file (an empty init.lua), and the app connects to that socket:

rex server run --listen unix:///tmp/ovvi-e2e.sock \
  --state-dir /tmp/ovvi-e2e-rex --config /tmp/ovvi-e2e-rex/init.lua

Every other rex command in the tests passes --autostart=false, or the CLI can start the user's server.

The run creates sessions named ovvi-e2e-* and stops the server at the end. The same rules as for herdr apply: existing sessions are read only, and Ovvi never calls focus methods.

Risks

RiskEffectPlan
GHOSTSNP is "v1, work in progress"a decoder breaks after a Rex updatefirst version skips the snapshot and seeds with format vt
The resize APC races the card sizewrapped or broken linesresize the surface only at the APC
A watcher gets the owner's gridclipped or scaled cards on small screenspin the surface grid to the APC size
Rex.app and Ovvi claim the same blockthe PTY size changes handsaccepted; Take Back as with herdr
Agents without OSC 7501no status for those agentsshow the agent by process name only
nc missing on a machineno connectionsocat or python3; a clear message
format over 1 MiBno seeded historytrim, or start with no history
Ovvi shows in rex client ls and Rex.appother users see the clientaccepted; the hello names it "Ovvi"

On this page