A session-based terminal workspace where the frontend is disposable and your shells are not.
Crash the terminal frontend, restart it, reattach, and your terminals keep running exactly where you left them.
hexe splits into four layers:
hexe terminal— the terminal UI frontend (aliases:hexe mux,hexe multiplexer).- shared frontend runtime — attach lifecycle, transport, and the frontend-side session projection.
hexe session/hexe ses— the session authority that owns canonical session state.hexe pod— one per pane. Owns the PTY, holds the shell, buffers output even while detached.
See architecture for the full picture.
A program can claim its own 256-colour table — one of 32 numbered slots — for the output it writes. Recolour that table and only its cells change — the rest of the pane stays exactly as it was, on screen and in scrollback, with no redraw from the application.
hexe palette set --ns 4 33=#ff00aa bg=#1a1020
hexe palette get # what is actually setAn application drives it directly, with nothing to negotiate first — claim a slot, print, release it:
printf '\033]1330;set;4;33=#ff00aa\033\\'
printf '\033]1330;use;4\033\\'
printf 'this line resolves colour 33 through slot 4\n'
printf '\033]1330;end\033\\'Slot 0 is what unclaimed output resolves against, slot 1 is hexe's own chrome — borders, status bar, float titles — and slots 2–31 are yours.
hexe holds the colours and resolves the indexes; it never decides which cells belong to which slot. Every cell records the slot that was current when it was written — the number itself, so there is no mapping to lose — and two slots are correct on screen at once, with a repaint reaching scrollback. Setting slot 0 recolours the ordinary palette; setting slot 1 recolours hexe's own furniture. Anything hexe does not recognise — an unknown name, a program that claims nothing, another terminal entirely — falls back to your own palette, so a default install looks exactly as it did before.
See the palette protocol for the sequences to emit.
OSC 1331 can soften ordinary ANSI, 256-colour, default or truecolour text against its effective cell background without changing the application's SGR palette choices.
printf '\033]1331;use;fg=30\033\\'
printf '\033[31m30%% red, 70%% cell background\033[0m\n'
printf '\033]1331;end\033\\'The percentage is recorded per cell and composes with OSC 1330 namespaces. See foreground colour mixing for the exact private protocol, fallback rules, capability query and host-palette refresh behavior.
Everything hexe knows about its panes, floats, tabs and session has one definition — the live Lua API — and a socket hands that same API to any program that can open one. There is no second list of fields to drift out of step with the first, and nothing has to scrape formatted output for facts hexe holds exactly.
hexe api panes # every pane, as JSON
hexe api count '"panes"'
hexe api act '{"type":"split.v"}'
hexe api send '"904dd85…"' '"make test\n"'hexe api is a thin client; the socket is the interface. Frames are a 4-byte big-endian length and
a JSON body, so a gateway, a phone client behind one, or a shell script are all the same amount of
work. Instead of polling, a client can subscribe and be told.
Three doors, each with its own authority, because a grant a caller can decline is not a grant:
| socket | who holds it | may do |
|---|---|---|
api@<session>.sock |
you | everything |
plug@<session>.<name>.sock |
one plugin | only what it declared |
pane@<uuid>.sock |
whatever runs in that pane | read and type into that pane |
A plugin is a package that declares what it needs — stream, typing, keyboard, popup — and is
handed a socket carrying exactly that; a verb it did not ask for is refused by name. A pane's socket
is exported to its shell as $HEXE_PANE_API_SOCKET, so a program in a pane finally has something it
can safely be given: a session-wide call is refused, and a selector naming another pane resolves to
nothing rather than to the caller's own.
For another Lua — a shell, an editor, a sibling tool — hexe lua-api prints a plain-Lua client, and
the client verb returns the same source over the socket for a host that cannot shell out:
local mux = hexe.connect() -- or connect_pane(), inside a pane
for _, pane in ipairs(mux.panes()) do print(pane.name, pane.cwd) endSee the control socket, access and plugins.
One document per feature in docs/, each opening with a recording of it running:
how it works, how it differs from tmux, what it cannot do, and where it lives in the tree. Every
claim in them was checked against the source or a running build, and the recordings are made by
hexe itself — see recording.
| Four processes | frontend, runtime, SES, pod — and why only one is disposable |
| Sessions | detach, reattach, replay, adoption, and what is written down |
| Pods | one daemon per pane: the PTY, the backlog ring, exactly-once input |
| Instances | two whole stacks on one machine that cannot see each other |
| Panes and tabs | the split tree, geometric focus, zoom, broadcast, select-and-swap |
| Floats | overlay panes with lifetimes: per-directory, sticky, exclusive, sandboxed |
| Keybindings | no prefix: chords, conditions, and what happens to the key afterwards |
| Reading what happened | scrollback search, copy-mode, OSC 133 prompt marks |
| Overlays and popups | notifications, questions, pickers, keycast, pane labels |
| Painting | the bar, titles, sprites and popups are drawn by an external painter |
| Shell integration | what a shell reports to the mux, and how the prompt is drawn |
| Palette protocol | a program claims its own 256-colour table for the output it writes |
| Foreground mixing | mix ordinary ANSI foregrounds with each cell's effective background |
| Configuration | one Lua file, a schema that refuses typos, reload without losing panes |
| Project sessions | .hexe.lua, freezing a session, and the trust ledger |
| Isolation | namespaces and cgroups per pane — and what it needs from the kernel |
| The command line | addressing sessions, panes and pods from a script |
| The control socket | the live API over a socket: what any program can ask and do |
| Access | what a helper may do — stream, typing, keyboard, popup — declared and enforced |
| Plugins | install, declare, approve, remove — a package, not a command string |
| Streaming a pane | a pane's bytes, who is watching, and how to cut them off |
| Dictation | speech to text as a tool hexe drives, and the sign that a mic is open |
| Names | how panes and sessions get names, and what a name is allowed to be |
| Decorations | borders, titles and what a pane is allowed to draw around itself |
| Recording | hexe writes asciicasts of itself; every film in the docs was made that way |
Nix. The flake builds a static musl binary on x86_64-linux and aarch64-linux:
nix build # result/bin/hexe
nix run . -- --help
nix flake check # build and package smoke checksAdd this input to another flake:
inputs.hexe.url = "github:termworks/hexe/develop";Accept hexe in your flake's outputs arguments. In a development shell, add
hexe.packages.${system}.default to packages. For NixOS, pass hexe through
specialArgs, accept it in the module arguments, and use:
environment.systemPackages = [
hexe.packages.${pkgs.stdenv.hostPlatform.system}.default
];For Home Manager, use extraSpecialArgs and home.packages instead. The named package is
hexe.packages.${system}.hexe; it is the same as default.
Use github:termworks/hexe once this packaging is merged into the default branch.
Remote URLs require the changes to be committed and pushed first.
nix develop still opens the development shell. Example configuration and runtime
Lua files are installed under the package's share/hexe/; they are not copied into
your home directory automatically.
Each package and architecture uses one stable pin, such as
hexe-x86_64-linux, with --keep-revisions 5. The five newest pin revisions
are protected from cache cleanup; each binary retains its actual package version.
Older revisions become eligible for garbage collection and may need rebuilding.
Binary cache. The shared cache is termworks. Accept the flake's cache
configuration when prompted, or run cachix use termworks on the consumer machine.
When using Hexe as an input of another flake, configure the consumer's cache too.
Use a release tag whose cache workflow has succeeded:
nix build github:termworks/hexe/vX.Y.ZReplace vX.Y.Z with the desired published tag. Only pushed v* tags trigger
.github/workflows/nix-cache.yml; branch pushes do not publish. The workflow builds
on native x86_64 and ARM64 Linux runners, uploads and pins the runtime closures,
then checks downloads and CLI startup on fresh runners with builders disabled.
Create a Cachix per-cache write token and store it as the GitHub Actions secret
CACHIX_AUTH_TOKEN for this repository. Never commit the token. Modified build
inputs or features can require a new build rather than a cached download.
Build. Needs Zig 0.15.2. A static musl binary:
scripts/vendor-ghostty.sh # once: fetch + patch ghostty-vt
scripts/vendor-yazap.sh # once: fetch + patch yazap
zig build -Doptimize=ReleaseFast -Dstrip=true -Dtarget=x86_64-linux-muslThat is the whole build, and it is what CI runs — CI has no oslo, so the pins live in the
scripts rather than in a recipe. With oslo the recipes in
.make.lua wrap them. There is no Makefile; the targets come from oslo:
oslo make vendor # fetch the pinned dependencies and apply hexe's patches
oslo make build # the build above, then size and which hexe is on $PATH
oslo make install # …and copy it everywhere hexe already is
oslo make test # the Zig unit tests
oslo make configs # install config/ into ~/.config/hexe, then check it loads
oslo make smoke # the live end-to-end suite
oslo make # every target, with a line eachBuild with oslo make build rather than a bare zig build: the default is a Debug binary, which
is slow enough to look like a bug.
Run:
hexe terminal new # a new session, named after a pokemon
hexe terminal new --name work # or named by you
hexe # bare: attach to a session rooted here, or load ./.hexe.luaDetach and come back. Detach is a keybinding, so it is whatever your config says — there is no built-in chord:
hexe.key({ hexe.key.ctrl, hexe.key.alt, hexe.key.d }, hexe.action.detach())hexe session list # what is running, attached or not
hexe terminal attach work # by name, or by uuid prefixConfig lives at ~/.config/hexe/init.lua and is Lua. See configuration, and
keybindings for the binding language.
Started as bash and Python hacks wrapped around tmux. Absolutely cursed code. Shell scripts spawning tmux sessions, Python daemons talking to tmux through send-keys, config files that were basically more shell scripts. It was wild. But it worked, and it was the workflow I wanted.
Rewrote it properly in Rust on top of tmux-rs, got far, learned a lot about terminal internals. But that crate is mostly unsafe and you're still building on top of tmux's architecture rather than escaping it.
Then Ghostty came out. Saw what Mitchell was doing with Zig and decided to start from scratch. Zero regrets. Zig is a joy, Ghostty's VT implementation is solid, and the architecture finally matches what I actually wanted to build.