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
rexCLI. 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
| Rex | Ovvi | Notes |
|---|---|---|
| server (host) | machine, and its one Session | one server per account and machine; sessions() returns one entry |
| session | space | ordered; generated label such as crisp-forest |
| window | tab | ordered; each session has an active_window_id |
layer tiled | the tab's split tree | split.direction horizontal is .right, vertical is .down; ratio is the first child's share |
layer floating | none yet | a 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 |
| block | pane and terminal | one stable id, block:<uuidv7>, so Pane.id == Pane.terminalID |
| client | one Ovvi app on one machine | survives 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>throughExec.run. On This Mac this is a localProcess. 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, asExec.uploadalready sends them). The other way: a stream-local forward on the ControlMaster (ssh -S <ctl> -O forward -L <local.sock>:<socket>), with nonc. - 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.
URLSessionWebSocketTaskcannot open a Unix socket. rex server status --jsongives the socket path (endpoint) on each machine; the path differs between macOS and Linux.rex server startstarts the server. It is idempotent.- If
ncis missing,socatorpython3can carry the same bytes.
Each machine has two kinds of WebSocket:
| Socket | Path and subprotocol | Count | Carries |
|---|---|---|---|
| control | /api/control, rex.control.v1 | one per machine | JSON hello, welcome, request, response, event |
| data | /api/data, rex.data.terminal.v1 | at most one per block per client | binary: 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, thensession.bootstrap {event_scope: "all"}andsession.attachfor every session. Requireapi_revision >= 4. - Snapshot:
session.view {session_id}per session, in parallel. The view has every window, the tiled layout with ratios, and each window'sfocused_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.cwdis the real cwd, with no OSC 7 needed. - Create calls send
focus: false(session.new_windowdefaults to true). A new tab issession.new_window {session_id, layout: {block: {flavor, options: {cwd}}}, focus: false}; a new space issession.create {initial_windows: [...]}, thensession.attach(createdoes 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}: readsession.viewagain 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_movedandsession_label_changedchange the space list.block_eventwithprogram_status_changedorprogram_status_removed: update the agent status in place.block_eventwithprocess_changedorpwd_changed: update the pane's program and cwd.- A close with 1008 (
event backlog exceeded), or a dropped socket: connect again, sendreconnect_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
- 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. - Open the data socket on a new
nc -Uchannel. Within 5 s, send the binary HELLO:01and the 16 ticket bytes. - 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 is82and a reason byte. - 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 everyextra_*flag on. Then apply the live bytes. Output in the few ms between the snapshot and theformatreply is drawn twice. Shells and agent TUIs redraw, so this is harmless.formatfails withtoo_largeabove 1 MiB, so the app then sendstrimor 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
resizeAPC, not when the card changes size. No full frame follows a resize. The program redraws after SIGWINCH. child_exitedends 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.resizeand the colors withset_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
suppressesTerminalResponsespatch 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'sghostty_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 andsendKeysgoes away. - Typed text:
client.action {action: "com.superlogical.terminal.write", args: {data}}. This keeps text in order with keys. A directterminal.writeis 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
| Event | The app |
|---|---|
| close 4000 | drops the card; waits for the view |
| socket drops | sends RESUME (02, the latest token, a u64 count of stream bytes read) within 60 s; output from the gap arrives |
| RESUME fails | opens a new stream with a new ticket |
| close 1000 from the app | the 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, thenworking, thendoneorerror, thenidle.errorshows as done with the word "failed". - Space and tab status fold their blocks, as herdr's do.
- Rex has no
completion_seq. Ovvi uses adonerecord'supdated_atas the finish marker. "Seen" stays in the app, keyed by block, record id andupdated_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 takeover | session.own_block {block_ids, mode: "request"}; if the owner is not in client.list, mode: "take" |
| refused: watch, show Take Back | the result's owner is not Ovvi's client id |
| first key, paste or click, or Take Back | session.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 shown | close 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_changedevent 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_windowdefaults tofocus: true. - Ovvi never calls
session.focus_*. session.zoom_blockmoves focus to its block unless the call names thewindow_id. Zoom stays the app's own (Zoom).session.swap_blocksandsession.move_blockcan move focus with the block. Ovvi calls them only after a drag, as withpane.swap.- Ovvi reads
focused_block_idonce, 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:
- Public memberwise inits for
Snapshot,Workspace,Tab,Pane,AgentandSplitNode. They have only decoding inits now (Tabalso has one for the stand-in tab), andSnapshot.treeshas an internal setter. TerminalStreamas a struct of closures (events,input,resize,scroll,mouse,close). Today it parses herdr's NDJSON from aChanneland its init is internal.- A byte
Channel. TodayChannelgives stdout as UTF-8 lines. The WebSocket frames need raw bytes in and out. - A neutral error.
HerdrErroris the error type ofUpdate.errorandHostStore.Statetoday. Its messages name herdr, such as "herdr did not answer". It becomes an error with a fix string that the backend gives. - One function picks the backend per machine. Today the app makes a
HerdrClientin a few places (HostStore, the Open Session panel, window restore). - A lookup for
rexlike the one for herdr.Exec.herdr()asks the login shell for herdr's absolute path (D38). A Rex client needs the same forrex, or forncand the socket path fromrex 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.luaEvery 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
| Risk | Effect | Plan |
|---|---|---|
| GHOSTSNP is "v1, work in progress" | a decoder breaks after a Rex update | first version skips the snapshot and seeds with format vt |
| The resize APC races the card size | wrapped or broken lines | resize the surface only at the APC |
| A watcher gets the owner's grid | clipped or scaled cards on small screens | pin the surface grid to the APC size |
| Rex.app and Ovvi claim the same block | the PTY size changes hands | accepted; Take Back as with herdr |
| Agents without OSC 7501 | no status for those agents | show the agent by process name only |
nc missing on a machine | no connection | socat or python3; a clear message |
format over 1 MiB | no seeded history | trim, or start with no history |
Ovvi shows in rex client ls and Rex.app | other users see the client | accepted; the hello names it "Ovvi" |