Architecture

The shape of it

                       ┌──────────────────────────────┐
  shell ── PTY ────────┤          termwired           │
  vim   ── PTY ────────┤   per-user session runtime   │
  agent ── PTY ────────┤                              │
                       │  owns: PTYs, processes,      │
                       │  screen state, scrollback,   │
                       │  (later: workspaces, leases) │
                       └──────┬────────────┬──────────┘
                       control plane   data plane
                       (JSON, one     (framed in /
                        socket)        raw out, one
                              │        socket per session)
                              │            │
                    ┌─────────┴──┬─────────┴─┬───────────┐
                    │  tw (CLI)  │  browser  │  desktop  │
                    │   today    │   next    │   later   │
                    └────────────┴───────────┴───────────┘

One daemon per user. Applications keep believing they talk to an ordinary terminal — they do: an ordinary PTY whose master side is held by the runtime instead of by a window. Clients are interchangeable views that attach and detach at will; the session's lifetime belongs to the runtime alone.

Terminology

The traditional stack had comfortable names — terminal emulator, multiplexer, shell — that stop describing anything precisely once the ownership moves. Email solved this with role names (MUA, MTA, MDA) rather than program names, and that is the right instinct: these are roles, and one program may play several.

termrole
applicationwhat runs on the PTY slave side and believes it owns a terminal: bash, vim, htop, ssh. (What people loosely call a "terminal app".)
PTYthe kernel pseudo-terminal pair, unchanged since the eighties.
enginethe VT interpreter that turns an output byte stream into screen state (ghostty-vt here). Every legacy emulator has one buried inside; TermWire hoists it into the runtime.
sessionone PTY + its application + its canonical screen state and scrollback. The unit of persistence. Owned by the runtime, never by a window.
runtimetermwired: the per-user daemon that owns every session. The PipeWire of this story.
viewanything that renders a session and gathers input for it: tw in an outer terminal, a browser tab, a native window, a phone. Views are disposable; sessions are not.
controller / observera view that may write to the session vs. one that only watches. (Today every view is a controller; the lease is on the roadmap.)
bridgea protocol adapter between a view's transport and the runtime's sockets: termwire-webd for browsers; others may follow.
workspacea semantic group of sessions belonging to one task (planned).

In these terms, a classic terminal emulator is an engine and a view fused into one process that also owns the PTY; a multiplexer is a second engine wedged into the byte stream. Under TermWire, xterm or Ghostty can shed both jobs and be what they are best at: a view.

Components

PathWhat it is
daemon/termwired — the runtime: a single-threaded poll loop owning all sessions, both socket planes, and the VT state
protocol/wire-format types and socket helpers shared by daemon and clients
cli/tw — CLI client: new, ls, attach, kill
webd/termwire-webd — web bridge: serves the built web client and bridges WebSockets to the daemon's Unix sockets
web/browser client: Vue 3 + Motion + xterm.js, built with Vite
third_party/ghosttyvendored Ghostty; the daemon imports its ghostty-vt Zig module (libghostty-vt) for terminal emulation
third_party/xterm.jsvendored xterm.js 6.0.0 for the browser client

Runtime directory

Everything lives under $XDG_RUNTIME_DIR/termwire/ (fallback /tmp/termwire-<uid>/, mode 0700):

$XDG_RUNTIME_DIR/termwire/
    control.sock        control plane (one per user)
    session-1.sock      data plane (one per session)
    session-2.sock
    ...

Control plane

Newline-delimited JSON over control.sock: one request per line, one response per line, connection stays open. Structured, out-of-band, never tunneled through the terminal stream.

$ python3 -c '
import socket, json, os
s = socket.socket(socket.AF_UNIX)
s.connect(os.environ["XDG_RUNTIME_DIR"] + "/termwire/control.sock")
s.sendall(b"{\"op\":\"create_session\",\"cols\":120,\"rows\":40}\n")
print(s.recv(4096).decode())'

{"ok":true,"session":{"id":1,"pid":184532,"cols":120,"rows":40,
 "socket_path":"/run/user/1000/termwire/session-1.sock"}}

Current operations:

opfieldseffect
create_sessioncols, rows (optional)spawn $SHELL on a fresh runtime-owned PTY; returns the session and its data socket path
list_sessions—enumerate live sessions
kill_sessionidSIGHUP the session's process group; the session is reaped when the child exits

Workspaces, attach leases, permissions, and agent operations will be further ops on this same plane.

Data plane

One Unix socket per session. Deliberately dumb — this is the path every output byte takes, so no JSON, no policy, no parsing beyond a 5-byte frame header (type byte + u32le payload length), used in both directions.

typedirectionpayload
data (0)client → daemonkeyboard/stdin bytes, written verbatim to the PTY (max 4096 per frame; chunk larger writes)
resize (1)client → daemon4 bytes: cols u16le, rows u16le → TIOCSWINSZ and the runtime's terminal state
snapshot (2)daemon → clientsent once on attach: a VT byte sequence reconstructing the session's current screen — palette, modes, content, styles, cursor — generated from the runtime's canonical terminal state
output (3)daemon → clientraw PTY output

Attach semantics. Every PTY byte flows through the runtime's VT engine before being broadcast, so the runtime always holds the canonical screen. On attach the daemon serializes that screen (using ghostty-vt's TerminalFormatter, VT format) into one snapshot frame, then streams output frames. The daemon is single-threaded, so the snapshot is atomic with respect to the stream: output that arrives during snapshot generation is applied and broadcast after it, and every attached view of a session sees the same bytes in the same order — views cannot drift. The snapshot payload is itself VT, so any VT-speaking view replays it with the parser it already has; a structured binary snapshot for state-aware views can be added as a new frame type without disturbing this one.

Multiple clients may be attached; today all of them receive output and all of them may write (the one-controller lease is on the roadmap, not in the code).

The web stack

The browser client is deliberately not inside the daemon. termwire-webd is a separate binary that serves the built web UI and translates between WebSockets and the daemon's Unix sockets; the daemon never learns HTTP, and the browser never learns Unix sockets. Kill the bridge and every session keeps running — which is the whole point.

browser (Vue + xterm.js)
   │  ws://127.0.0.1:7181/ws/control      JSON, one per text frame
   │  ws://127.0.0.1:7181/ws/session/{id} binary in/out
   ▼
termwire-webd ── unix sockets ──► termwired
endpointbehavior
GET /the web client (static files from --root, default web/dist)
/ws/controleach text frame is one control request; each response line comes back as one text frame
/ws/session/{id}binary frames in = raw keyboard bytes; text frames in = {"resize":{"cols":N,"rows":N}}; binary frames out = raw PTY output. The bridge builds the data-plane framing, so the browser stays protocol-ignorant.

The client itself is Vue 3 with Motion for animation and xterm.js (pinned to the same 6.0.0 as third_party/xterm.js) for rendering: a session sidebar with live sizes and ages, create/kill, and a terminal pane that follows the browser layout and syncs the PTY size on attach and resize. termwire-webd binds 127.0.0.1 only and has no authentication yet — do not expose it.

The VT engine

The daemon links Ghostty's terminal core, consumed as the ghostty-vt Zig module straight from the vendored tree — the same VT emulation Ghostty ships to its users, not a reimplementation. This is what makes the runtime the canonical owner of screen state: every session's PTY output flows through ghostty.TerminalStream into a per-session ghostty.Terminal, and attach snapshots are serialized from that state with ghostty's TerminalFormatter. Any client — a reattaching CLI, a browser tab, a phone — is brought current from the runtime's copy instead of keeping its own.

Building

Requires Zig 0.16.0 — exactly. Zig build-system APIs shift between minor versions, and the vendored Ghostty is pinned to match; the two move together (see the README for the current pins).

$ git clone https://github.com/toppk/termwire
$ cd termwire
$ git submodule update --init --depth 1
$ zig build
$ zig build test

Day-to-day tasks go through just (bare just lists the recipes; each echoes the commands it runs, and build-graph work delegates to zig build). The web client additionally needs node/npm, only at build time:

$ just serve                       # npm build + bridge on http://127.0.0.1:7181

Try it:

$ zig-out/bin/termwired            # terminal 1: the runtime
$ zig-out/bin/tw new               # terminal 2: create + attach
$ MARCO=polo                       #   ... do some work ...
                                   #   Ctrl-\ detaches; close the window
$ zig-out/bin/tw ls                # later, any terminal:
ID     PID      SIZE      SOCKET
1      184532   120x40    /run/user/1000/termwire/session-1.sock
$ zig-out/bin/tw attach 1          # your shell, exactly as you left it
$ echo $MARCO
polo

Honest limitations, today

  • Attach replays the current screen, not scrollback history — that needs runtime-owned scrollback serialization.
  • No backpressure: a stuck client can stall the loop on write.
  • No single-instance lock; a second daemon stomps the first's control socket.
  • No controller lease: every attached client can type, and every client's resize is applied — with two differently-sized views on one session, the last resize wins.
  • No authentication on the web bridge; it binds localhost only.
  • Daemon crash takes sessions with it (see the manifesto FAQ).

Milestones

  1. Done: runtime daemon, PTY ownership, control plane, data plane, CLI attach/detach, ghostty-vt linked and tested.
  2. Done: browser client — termwire-webd bridge plus the Vue/xterm.js UI in web/.
  3. Done: runtime-owned screen state — every session's output flows through a ghostty-vt Terminal in the daemon, and attach replays a snapshot of it. Next: extend the snapshot with scrollback history.
  4. Workspace model; read-only observers; controller leases.
  5. Native desktop client — informed by the web client, and likely thinner: less an emulator, more a window manager for sessions ("bring this terminal to the foreground").
  6. Mobile, collaboration, and the agent API.