repos / zmx

session persistence for terminal processes
git clone https://github.com/neurosnap/zmx.git

  1<h1>
  2<p align="center">
  3  <img src="./logo.png" alt="Logo" width="128">
  4  <br>zmx
  5</h1>
  6<p align="center">
  7  Session persistence for terminal processes.
  8  <br />
  9  <a href="https://zmx.sh">Docs</a>
 10  ·
 11  <a href="https://bower.sh/you-might-not-need-tmux">You might not need tmux</a>
 12  ·
 13  Sponsored by <a href="https://pico.sh">pico.sh</a>
 14</p>
 15
 16## features
 17
 18- Persist terminal shell sessions
 19- Ability to attach and detach from a shell session without killing it
 20- Native terminal scrollback
 21- Multiple clients can connect to the same session
 22- Re-attaching to a session restores previous terminal state and output
 23- Send commands to a session without attaching to it
 24- Print scrollback history of a terminal session in plain text
 25- Works on mac and linux
 26- This project does **NOT** provide windows, tabs, or splits
 27
 28## demos
 29
 30- [zmx - intro](https://youtu.be/UIXj0_rhPgI)
 31- [zmx - ai portal](https://youtu.be/CV3skPYHP4Q)
 32
 33## install
 34
 35### binaries
 36
 37- https://zmx.sh/a/zmx-0.5.0-linux-aarch64.tar.gz
 38- https://zmx.sh/a/zmx-0.5.0-linux-x86_64.tar.gz
 39- https://zmx.sh/a/zmx-0.5.0-macos-aarch64.tar.gz
 40- https://zmx.sh/a/zmx-0.5.0-macos-x86_64.tar.gz
 41
 42### homebrew
 43
 44```bash
 45brew install neurosnap/tap/zmx
 46```
 47
 48### packages (unofficial)
 49
 50- [Alpine Linux](https://pkgs.alpinelinux.org/package/edge/testing/x86_64/zmx)
 51- [Arch AUR tracking releases](https://aur.archlinux.org/packages/zmx)
 52- [Arch AUR tracking git](https://aur.archlinux.org/packages/zmx-git)
 53- [openSUSE Tumbleweed](https://software.opensuse.org/package/zmx)
 54- [Gentoo (overlay)](https://codeberg.org/samuelhautamaki/samuelhautamaki-gentoo)
 55- [zmx-rpm packaging](https://github.com/engie/zmx-rpm/)
 56
 57### src
 58
 59- Requires zig `v0.15`
 60- Clone the repo
 61- Run build cmd
 62
 63Be sure to add `~/.local/bin` to your `PATH`:
 64
 65```bash
 66zig build -Doptimize=ReleaseSafe --prefix ~/.local
 67```
 68
 69## usage
 70
 71> [!IMPORTANT]
 72> We recommend closing the terminal window to detach from the session but you can also press `ctrl+\` or run `zmx detach`.
 73
 74```
 75zmx - session persistence for terminal processes
 76
 77Usage: zmx <command> [args...]
 78
 79Commands:
 80  [a]ttach <name> [command...]             Attach to session, creating if needed
 81  [r]un <name> [-d] [--fish] [command...]  Send command without attaching
 82  [s]end <name> <text...>                  Send raw input to session PTY
 83  [p]rint <name> <text...>                 Inject text into session display
 84  [wr]ite <name> <file_path>               Write stdin to file_path through the session
 85  [d]etach                                 Detach all clients (ctrl+\\ for current client)
 86  [l]ist|ls [--short]                      List active sessions
 87  [k]ill <name>... [--force]               Kill session and all attached clients
 88  [hi]story <name> [--vt|--html]           Output session scrollback
 89  [w]ait <name>...                         Wait for session tasks to complete
 90  [t]ail <name>...                         Follow session output
 91  [c]ompletions <shell>                    Shell completions (bash, zsh, fish)
 92  [v]ersion                                Show version
 93  [h]elp                                   Show this help
 94
 95Attach:
 96  This will spawn a login $SHELL with a PTY.  You can provide a
 97  command instead of creating a shell.
 98
 99  Examples:
100    zmx attach dev
101    zmx attach dev vim
102
103History:
104  This should generally be used with `tail` to print the last lines
105  of the session's scrollback history.
106
107  Examples:
108    zmx history <session> | tail -100
109
110Run:
111  Commands are passed as-is: do not wrap in quotes.
112  Commands run sequentially: do not send multiple in parallel.
113  Avoid interactive programs (pagers, editors, prompts): they hang.
114
115  `--fish` is required when the session runs fish shell.
116
117  If the command hangs, send Ctrl+C to recover:
118    zmx run <session> $(printf '\x03')
119
120  If the command hangs, print the history to see the error:
121    zmx history <session> | tail -100
122
123  `-d` will detach from the calling terminal. Use `wait` to track
124  its status.
125
126  Examples:
127    zmx run dev ls
128    zmx run dev --fish ls src
129    zmx run dev zig build
130    zmx run dev grep -r TODO src
131    zmx run dev git -c core.pager=cat diff
132
133Send:
134  Sends raw text to the session's PTY input (fire-and-forget).
135  Unlike `run`, no completion marker is appended and no exit code
136  is tracked.  Useful for TUI applications, interactive prompts,
137  or any program that reads stdin directly.
138
139  Text is sent byte-for-byte with no automatic carriage return.
140  Append \r yourself when you want the shell to execute a command.
141
142  Text can also be piped via stdin:
143    printf 'ls -la\r' | zmx send dev
144
145  Examples:
146    printf 'echo hello\r' | zmx send dev
147    zmx send dev $(printf '\x03')
148    zmx send dev /compact
149
150Print:
151  Injects text directly into the session display and scrollback.
152  Never touches the PTY input -- the shell sees nothing.
153  Caller is responsible for newlines (\\r\\n).
154
155  Examples:
156    printf '\\r\\nhello\\r\\n' | zmx print dev
157    zmx print dev "$(printf '\\r\\nalert\\r\\n')"
158
159Write:
160  Writes stdin to file_path inside the session. Works over SSH.
161  file_path can be absolute or relative to the session shell's cwd.
162  Requires base64 and printf in the remote environment.
163  Large files are chunked automatically (~48KB per chunk).
164  File path must not contain single quotes.
165
166  Examples:
167    echo "hello" | zmx write dev /tmp/hello.txt
168    cat main.zig | zmx write dev src/main.zig
169
170Wait:
171  Used with a detached run task to track its status.  Multiple
172  sessions can be provided.
173
174  Examples:
175    zmx run -d dev sleep 10
176    zmx wait dev
177    zmx wait dev other
178
179Environment variables:
180  SHELL                Default shell for new sessions
181  ZMX_DIR              Socket directory (priority 1)
182  XDG_RUNTIME_DIR      Socket directory (priority 2)
183  TMPDIR               Socket directory (priority 3)
184  ZMX_SESSION          Session name (injected automatically)
185  ZMX_SESSION_PREFIX   Prefix added to all session names
186  ZMX_DIR_MODE         Sets mode for socket and log directories (octal, defaults to 0750)
187  ZMX_LOG_MODE         Sets mode for log files (octal, defaults to 0640)
188```
189
190## shell prompt
191
192When you attach to a `zmx` session, we don't provide any indication that you are inside `zmx`. We do provide an environment variable `ZMX_SESSION` which contains the session name.
193
194We recommend checking for that env var inside your prompt and displaying some indication there.
195
196### fish
197
198Place this file in `~/.config/fish/config.fish`:
199
200```fish
201functions -c fish_prompt _original_fish_prompt 2>/dev/null
202
203function fish_prompt --description 'Write out the prompt'
204  if set -q ZMX_SESSION
205    echo -n "[$ZMX_SESSION] "
206  end
207  _original_fish_prompt
208end
209```
210
211### bash and zsh
212
213Depending on the shell, place this in either `.bashrc` or `.zshrc`:
214
215```bash
216if [[ -n $ZMX_SESSION ]]; then
217  export PS1="[$ZMX_SESSION] ${PS1}"
218fi
219```
220
221### powerlevel10k zsh theme
222
223[powerlevel10k](https://github.com/romkatv/powerlevel10k) is a theme for zsh that overwrites the default prompt statusline.
224
225Place this in `.zshrc`:
226
227```bash
228function prompt_my_zmx_session() {
229  if [[ -n $ZMX_SESSION ]]; then
230    p10k segment -b '%k' -f '%f' -t "[$ZMX_SESSION]"
231  fi
232}
233POWERLEVEL9K_RIGHT_PROMPT_ELEMENTS+=my_zmx_session
234```
235
236### oh-my-posh
237
238[oh-my-posh](https://ohmyposh.dev) is a popular shell themeing and prompt engine. This code will display an icon and session name as part of the prompt if (and only if) you have zmx active:
239
240```toml
241[[blocks.segments]]
242   template = '{{ if .Env.ZMX_SESSION }} {{ .Env.ZMX_SESSION }}{{ end }}'
243   foreground = 'p:orange'
244   background = 'p:black'
245   type = 'text'
246   style = 'plain'
247```
248
249### Starship
250
251[Starship](https://starship.rs) is a popular shell themeing and prompt engine. This code will display an icon and session name as part of the prompt if (and only if) you have zmx active:
252
253```toml
254format = """
255${env_var.ZMX_SESSION}\
256...
257"""
258
259[env_var.ZMX_SESSION]
260symbol = " "
261format = "[$symbol$env_value]($style) "
262description = "zmx session name"
263style = "bold magenta"
264```
265
266## shell completion
267
268Shell auto-completion for `zmx` commands and session names can be enabled using the `completions` subcommand. Once configured, you'll get auto-complete for both local `zmx` commands and sessions:
269
270```bash
271ssh remote-server zmx attach session-na<TAB>
272# <- auto-complete suggestions appear here
273```
274
275> NOTICE: when installing `zmx` with `homebrew` completions are automatically installed.
276
277### bash
278
279Add this to your `.bashrc` file:
280
281```bash
282if command -v zmx &> /dev/null; then
283  eval "$(zmx completions bash)"
284fi
285```
286
287### zsh
288
289Add this to your `.zshrc` file:
290
291```zsh
292if command -v zmx &> /dev/null; then
293  eval "$(zmx completions zsh)"
294fi
295```
296
297### fish
298
299Add this to `~/.config/fish/completions/zmx.fish`:
300
301```fish
302if type -q zmx
303  zmx completions fish | source
304end
305```
306
307## session picker
308
309You can add an interactive session picker to your shell that lets you fuzzy-find existing sessions, preview their scrollback history, or create new ones -- all from a single prompt. This is especially useful for remote SSH workflows: add it to your shell startup so that connecting to a machine immediately presents the picker.
310
311Requires [fzf](https://github.com/junegunn/fzf).
312
313- **Enter** selects a matched session (or creates one if no sessions exist)
314- **Ctrl-N** creates a new session using the typed query, even when a fuzzy match is highlighted
315
316<details>
317<summary>bash and zsh</summary>
318
319```bash
320zmx-select() {
321  local display
322  display=$(zmx list 2>/dev/null | while IFS=$'\t' read -r name pid clients created dir; do
323    name=${name#session_name=}
324    pid=${pid#pid=}
325    clients=${clients#clients=}
326    dir=${dir#started_in=}
327    printf "%-20s  pid:%-8s  clients:%-2s  %s\n" "$name" "$pid" "$clients" "$dir"
328  done)
329
330  local output query key selected session_name
331  output=$({ [[ -n "$display" ]] && echo "$display"; } | fzf \
332    --print-query \
333    --expect=ctrl-n \
334    --height=80% \
335    --reverse \
336    --prompt="zmx> " \
337    --header="Enter: select | Ctrl-N: create new" \
338    --preview='zmx history {1}' \
339    --preview-window=right:60%:follow \
340  )
341  local rc=$?
342
343  query=$(echo "$output" | sed -n '1p')
344  key=$(echo "$output" | sed -n '2p')
345  selected=$(echo "$output" | sed -n '3p')
346
347  if [[ "$key" == "ctrl-n" && -n "$query" ]]; then
348    session_name="$query"
349  elif [[ $rc -eq 0 && -n "$selected" ]]; then
350    session_name=$(echo "$selected" | awk '{print $1}')
351  elif [[ -n "$query" ]]; then
352    session_name="$query"
353  else
354    return 130
355  fi
356
357  zmx attach "$session_name"
358}
359```
360
361You can call `zmx-select` manually, bind it to a key, or auto-launch it on shell startup when outside a zmx session. With `&& exit`, the normal flow becomes: connect via SSH → pick a session → work → detach or exit the session → SSH disconnects automatically. Cancelling the picker with **Ctrl-C** drops you into a regular shell as an escape hatch.
362
363```bash
364if command -v zmx &> /dev/null && command -v fzf &> /dev/null && [[ -z "$ZMX_SESSION" ]]; then
365  zmx-select && exit
366fi
367```
368
369</details>
370
371## session prefix
372
373We allow users to set an environment variable `ZMX_SESSION_PREFIX` which will prefix the name of the session for all commands. This means if that variable is set, every command that accepts a session will be prefixed with it.
374
375```bash
376export ZMX_SESSION_PREFIX="d."
377zmx a runner # ZMX_SESSION=d.runner
378zmx a tests  # ZMX_SESSION=d.tests
379zmx k tests  # kills d.tests
380zmx wait     # suspends until all tasks prefixed with "d." are complete
381```
382
383## philosophy
384
385The entire argument for `zmx` instead of something like `tmux` that has windows, panes, splits, etc. is that job should be handled by your os window manager. By using something like `tmux` you now have redundant functionality in your dev stack: a window manager for your os and a window manager for your terminal. Further, in order to use modern terminal features, your terminal emulator **and** `tmux` need to have support for them. This holds back the terminal enthusiast community and feature development.
386
387Instead, this tool specifically focuses on session persistence and defers window management to your os wm.
388
389## ssh workflow
390
391Using `zmx` with `ssh` is a first-class citizen. Instead of using `ssh` to remote into your system with a single terminal and `n` tmux panes, you open `n` terminals and run `ssh` for all of them. This might sound tedious, but there are tools to make this a delightful workflow.
392
393First, create an `ssh` config entry for your remote dev server:
394
395```bash
396Host = d.*
397    HostName 192.168.1.xxx
398
399    RemoteCommand zmx attach %k
400    RequestTTY yes
401    ControlPath ~/.ssh/cm-%r@%h:%p
402    ControlMaster auto
403    ControlPersist 10m
404```
405
406Architecturally, `ssh` supports multiplexing multiple channels of communication within a single connection to a server. `ControlMaster` is the setting that tells `ssh` to multiplex multiple PTY sessions to a single server over one tcp connection. Neat!
407
408Now you can spawn as many terminal sessions as you'd like:
409
410```bash
411ssh d.term
412ssh d.irc
413ssh d.pico
414ssh d.dotfiles
415```
416
417Because the `attach` command is essentially an "upsert", this will create or attach to each session.
418
419Now you can use the [`autossh`](https://linux.die.net/man/1/autossh) tool to make your ssh connections auto-reconnect. For example, if you have a laptop and close/open your lid it will automatically reconnect all your ssh connections:
420
421```bash
422autossh -M 0 -q d.term
423```
424
425Or create an `alias`/`abbr`:
426
427```fish
428abbr -a ash "autossh -M 0 -q"
429```
430
431```bash
432ash d.term
433ash d.irc
434ash d.pico
435ash d.dotifles
436```
437
438Wow! Now you can setup all your os tiling windows how you like them for your project and have as many windows as you'd like, almost replicating exactly what `tmux` does but with native windows, tabs, splits, and scrollback! It also has the added benefit of supporting all the terminal features your emulator supports, no longer restricted by what `tmux` supports.
439
440The end-game here would be to leverage your window manager's ability to automatically arrange your windows for each project with a single command.
441
442## socket file location
443
444Each session gets its own unix socket file. The default location depends on your environment variables (checked in priority order):
445
4461. `ZMX_DIR` => uses exact path (e.g., `/custom/path`)
4471. `XDG_RUNTIME_DIR` => uses `{XDG_RUNTIME_DIR}/zmx` (recommended on Linux, typically results in `/run/user/{uid}/zmx`)
4481. `TMPDIR` => uses `{TMPDIR}/zmx-{uid}` (appends uid for multi-user safety)
4491. `/tmp` => uses `/tmp/zmx-{uid}` (default fallback, appends uid for multi-user safety)
450
451## permissions
452
453You can configure the permissions for the socket directory and log files using the following environment variables:
454
455- `ZMX_DIR_MODE` => sets the mode for the socket and log directories (octal, defaults to `0750`)
456- `ZMX_LOG_MODE` => sets the mode for the log files (octal, defaults to `0640`)
457
458This is particularly useful when running `zmx` as a system service with a shared group. For example, setting `ZMX_DIR_MODE=0770` and `ZMX_LOG_MODE=0660` allows group members to attach to the session.
459
460## debugging
461
462We store global logs for cli commands in `{socket_dir}/logs/zmx.log`. We store session-specific logs in `{socket_dir}/logs/{session_name}.log`. Right now they are enabled by default and cannot be disabled. The idea here is to help with initial development until we reach a stable state.
463
464## a note on configuration
465
466We are evaluating what should be configurable and what should not. Every configuration option is a burden for us maintainers. For example, being able to change the default detach shortcut is difficult in a terminal environment.
467
468## a smol contract
469
470- Write programs that solve a well defined problem.
471- Write programs that behave the way most users expect them to behave.
472- Write programs that a single person can maintain.
473- Write programs that compose with other smol tools.
474- Write programs that can be finished.
475
476## known issues
477
478- When upgrading versions of `zmx` where we make changes to the underlying IPC communication, it will kill all your sessions because it cannot communicate through the daemon socket properly
479- Terminal state restoration with nested `zmx` sessions through SSH: host A `zmx` -> SSH -> host B `zmx`
480  - Specifically cursor position gets corrupted
481- When re-attaching and kitty keyboard mode was previously enable, we try to re-send that CSI query to re-enable it
482  - Some programs don't know how to handle that CSI query (e.g. `psql`) so when you type it echos kitty escape sequences erroneously
483
484## impl
485
486- The `daemon` and client processes communicate via a unix socket
487- Both `daemon` and `client` loops leverage `poll()`
488- Each session creates its own unix socket file
489- We restore terminal state and output using `libghostty-vt`
490
491### libghostty-vt
492
493We use `libghostty-vt` to restore the previous state of the terminal when a client re-attaches to a session.
494
495How it works:
496
497- user creates session `zmx attach term`
498- user interacts with terminal stdin
499- stdin gets sent to pty via daemon
500- daemon sends pty output to client *and* `ghostty-vt`
501- `ghostty-vt` holds terminal state and scrollback
502- user disconnects
503- user re-attaches to session
504- `ghostty-vt` sends terminal snapshot to client stdout
505
506In this way, `ghostty-vt` doesn't sit in the middle of an active terminal session, it simply receives all the same data the client receives so it can re-hydrate clients that connect to the session. This enables users to pick up where they left off as if they didn't disconnect from the terminal session at all. It also has the added benefit of being very fast, the only thing sitting in-between you and your PTY is a unix socket.
507
508## prior art
509
510Below is a list of projects that inspired me to build this project. Architecturally, `zmx` uses aspects of both projects. For example, `shpool` inspired the idea of having libghostty restore the terminal state on reattach. Abduco inspired the idea of one daemon (and unix socket) per session.
511
512### shpool
513
514https://github.com/shell-pool/shpool
515
516`shpool` is a service that enables session persistence by allowing the creation of named shell sessions owned by `shpool` so that the session is not lost if the connection drops.
517
518### abduco
519
520https://github.com/martanne/abduco
521
522abduco provides session management (i.e. it allows programs to be run independently from its controlling terminal). Together with dvtm it provides a simpler alternative to tmux or screen.
523
524## comparison
525
526| Feature                        | zmx | shpool | abduco | dtach | tmux |
527| ------------------------------ | --- | ------ | ------ | ----- | ---- |
528| 1:1 Terminal emulator features | ✓   | ✓      | ✓      | ✓     | ✗    |
529| Terminal state restore         | ✓   | ✓      | ✗      | ✗     | ✓    |
530| Window management              | ✗   | ✗      | ✗      | ✗     | ✓    |
531| Multiple clients per session   | ✓   | ✗      | ✓      | ✓     | ✓    |
532| Native scrollback              | ✓   | ✓      | ✓      | ✓     | ✗    |
533| Configurable detach key        | ✗   | ✓      | ✓      | ✓     | ✓    |
534| Auto-daemonize                 | ✓   | ✓      | ✓      | ✓     | ✓    |
535| Daemon per session             | ✓   | ✗      | ✓      | ✓     | ✗    |
536| Session listing                | ✓   | ✓      | ✓      | ✗     | ✓    |
537
538## community tools
539
540- [pi-zmx](https://github.com/deevus/pi-zmx) -- [pi](https://pi.dev) extension for zmx.
541- [zsm](https://github.com/mdsakalu/zmx-session-manager) -- TUI session manager for zmx. List, preview, filter, and kill sessions from an interactive terminal UI.
542- [zmosh](https://github.com/mmonad/zmosh) -- A fork of zmx that adds encrypted UDP auto-reconnect for remote sessions (like mosh).