Skip to content

Commit 98368f3

Browse files
philzexedev-shelley
andcommitted
oss/exe-scroll: attach-or-create terminal sessions with ghostty scrollback
Prompt: In the exe-dtach-ghostty branch there's a zdtach binary that's like dtach but written in zig and uses ghostty for scrollback. I want you to make a clean commit that adds it to the oss/ dir. call it exe-scroll. The default should take one argument, a path to place the socket in. And either attach or create depending on whether the file exists. We should securely create any intervening dirs if applicable. If the file is deleted while we're alive we should securely recreate it (borrowing an idea from abduco). No need for dtach protocol compatibility; do whatever is natural and good and easy to evolve and test. Follow-ups: Why periodic watchdog? Can we set up a file watcher? / Let's do just inotify and whenever bytes come across. I don't want a timer. / The race I'm worried about is another session using the same path and them competing for the file. Suggestions? / Undo that. Let's make recreation tied to a signal sent to the process like done by abduco. Get rid of inotify and so on. / I don't want the pid files. Just be simple -- just let the server understand SIGUSR1 to recreate as necessary. / Simplify and fix: mention zmx alongside dtach/abduco; drop the -D dump, the -e/-E detach character, and the -s scrollback flag (default to 10,000 lines); detach by sending SIGUSR2 to the process instead; drop master/slave terminology; replace the Python test harness with Zig's native test harness; and use mise to fetch the toolchain consistently instead of a hand-rolled installer. / Doc/cleanup pass: drop the "no wire-protocol compatibility" paragraph and the "intervening directories are created securely" line and the dtach-compatibility sentence above the framing (the code should read sensibly from first principles); confirm whether Zig really doesn't expose the libc bindings we hand-roll and lean on std.c where it does; collapse the two scrollback concepts (lines + bytes) into a single byte budget and remove the scrollbackBytes() helper; and drop the abduco reference from the socket-recreation comment. / Portability: stop hardcoding the Linux ABI in the libc binding block -- source the termios layout, signal numbers, ioctl encodings, errno values, and flags from std.c/std.posix so they follow the target platform, letting the source build natively on Linux, macOS, and the BSDs (verified the binding block compiles for aarch64/x86_64-macos, both linux-musl arches, and freebsd; filled the one std gap, Darwin's missing T.IOCSWINSZ). / Make `make` work out of the box: a bare `make` now fetches Ghostty at the pinned revision into ./.ghostty and symlinks it, instead of defaulting GHOSTTY_SRC to a hardcoded absolute path; pass GHOSTTY_SRC=... to use an existing checkout. / Set a per-role process title so `ps` distinguishes the backgrounded "exe-scroll: session <sock>" from each "exe-scroll: attach <sock>" (rewrites the argv string region in place, the portable Linux/macOS/BSD mechanism); dup progname and sockname out of argv first so the rewrite doesn't corrupt them. Co-authored-by: Shelley <shelley@exe.dev>
1 parent c382b22 commit 98368f3

11 files changed

Lines changed: 2025 additions & 0 deletions

File tree

exe-scroll/.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
/zig-out
2+
/.zig-cache
3+
/ghostty-src
4+
/.ghostty

exe-scroll/LICENSE

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) 2026 exe.dev
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
SOFTWARE.

exe-scroll/Makefile

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
# Developer Makefile for exe-scroll.
2+
#
3+
# exe-scroll imports Ghostty's `ghostty-vt` Zig module directly, so it needs a
4+
# Ghostty checkout to build against. A bare `make` fetches Ghostty at the pinned
5+
# revision into ./.ghostty automatically, so it works out of the box. To build
6+
# against a checkout you already have, point GHOSTTY_SRC at it:
7+
#
8+
# make # -> zig-out/bin/exe-scroll
9+
# make test # build + run the e2e tests
10+
# make GHOSTTY_SRC=/path/to/ghostty # use an existing checkout instead
11+
12+
ZIG ?= zig
13+
OPTIMIZE ?= ReleaseFast
14+
BIN := zig-out/bin/exe-scroll
15+
16+
# Pinned Ghostty revision (kept in sync with build-static.sh).
17+
GHOSTTY_REV ?= 48d3e972d839999745368b156df396d9512fd17b
18+
GHOSTTY_REPO_URL ?= https://github.com/ghostty-org/ghostty.git
19+
# Where `make` clones Ghostty when GHOSTTY_SRC isn't supplied.
20+
GHOSTTY_CACHE := .ghostty
21+
# Use the caller's checkout if given, otherwise our managed clone.
22+
GHOSTTY_SRC ?=
23+
24+
$(BIN): exe-scroll.zig build.zig build.zig.zon ghostty-src
25+
$(ZIG) build -Doptimize=$(OPTIMIZE)
26+
27+
.PHONY: build test clean
28+
build: $(BIN)
29+
30+
# `zig build test` builds the binary and runs test_e2e.zig against it.
31+
test: ghostty-src
32+
$(ZIG) build test
33+
34+
# Create the ./ghostty-src symlink the build.zig.zon dependency resolves
35+
# through. If GHOSTTY_SRC is set, point at it; otherwise fetch a pinned clone.
36+
.PHONY: ghostty-src
37+
ghostty-src:
38+
ifeq ($(strip $(GHOSTTY_SRC)),)
39+
@if [ ! -d "$(GHOSTTY_CACHE)/.git" ]; then \
40+
echo "Cloning Ghostty into $(GHOSTTY_CACHE)..."; \
41+
git clone --filter=tree:0 "$(GHOSTTY_REPO_URL)" "$(GHOSTTY_CACHE)"; \
42+
fi
43+
@git -C "$(GHOSTTY_CACHE)" cat-file -e "$(GHOSTTY_REV)^{commit}" 2>/dev/null \
44+
|| git -C "$(GHOSTTY_CACHE)" fetch --filter=tree:0 origin "$(GHOSTTY_REV)"
45+
@git -C "$(GHOSTTY_CACHE)" checkout --quiet --detach "$(GHOSTTY_REV)"
46+
@ln -sfn "$(GHOSTTY_CACHE)" ghostty-src
47+
else
48+
@ln -sfn "$(GHOSTTY_SRC)" ghostty-src
49+
endif
50+
51+
clean:
52+
rm -rf .zig-cache zig-out ghostty-src
53+
54+
# `make distclean` also removes the managed Ghostty clone.
55+
.PHONY: distclean
56+
distclean: clean
57+
rm -rf $(GHOSTTY_CACHE)

exe-scroll/README.md

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
# exe-scroll
2+
3+
`exe-scroll` is a small terminal session multiplexer in the spirit of
4+
[dtach](https://github.com/crigler/dtach),
5+
[abduco](https://github.com/martanne/abduco), and
6+
[zmx](https://github.com/qbit/zmx), written in Zig. It hosts a PTY-backed
7+
command behind a Unix socket so you can attach and detach without disturbing the
8+
program — and it uses Ghostty's `ghostty-vt` terminal emulator to keep
9+
scrollback, so reattaching repaints the screen (and optional history) exactly:
10+
colors, styles, cursor and all.
11+
12+
## Usage
13+
14+
The default invocation takes a single argument — a path for the session socket
15+
— and **attaches** to it if a live session is there, otherwise **creates** it:
16+
17+
```sh
18+
# Create (no session yet): runs $SHELL behind /tmp/work/session.sock.
19+
exe-scroll /tmp/work/session.sock
20+
21+
# ...later, from anywhere: attach to the same session.
22+
exe-scroll /tmp/work/session.sock
23+
24+
# Create with an explicit command (everything after `--` is the command).
25+
exe-scroll /tmp/work/session.sock -- bash -l
26+
```
27+
28+
**Detach** (leaving the session running) by sending the attach client
29+
`SIGUSR2`:
30+
31+
```sh
32+
kill -USR2 <pid-of-exe-scroll>
33+
```
34+
35+
The session keeps running; reattach later with the same command.
36+
37+
### Telling the processes apart
38+
39+
Each session involves a backgrounded *session server* (owns the pty and
40+
scrollback) and one *attach client* per attachment. They rewrite their command
41+
line so `ps` shows which is which:
42+
43+
```
44+
$ ps -o pid,command
45+
4258 exe-scroll: attach /tmp/work/session.sock
46+
2976 exe-scroll: session /tmp/work/session.sock
47+
```
48+
49+
So the `session` line is the one to send `SIGUSR1` (recreate socket), and an
50+
`attach` line is the one to send `SIGUSR2` (detach).
51+
52+
### Options
53+
54+
| flag | meaning |
55+
|------|---------|
56+
| `-R none\|screen\|scrollback` | what to replay on attach (default `scrollback`) |
57+
| `--version` | print version and exit |
58+
| `-h`, `--help` | help |
59+
60+
Options may appear before or after the socket path.
61+
62+
## Design notes
63+
64+
- **Attach-or-create by default.** A single path argument is all you need; the
65+
tool connects if a session is live, otherwise spawns one. Intervening
66+
directories are created securely (mode `0700`).
67+
- **A session server and attach clients.** Creating a session forks a
68+
backgrounded *session server* that owns the pty and the scrollback. Each
69+
`exe-scroll <socket>` you run is an *attach client* that connects to it over
70+
the socket and relays your terminal.
71+
- **Signal-driven socket recreation (an abduco idea).** There's no polling
72+
timer and no file watcher. The session server understands `SIGUSR1`: on
73+
receipt, if its socket file is missing (or has been replaced), it binds a
74+
fresh one. So if a socket gets deleted out from under a live session,
75+
`kill -USR1 <server-pid>` brings it back and you can reattach. On exit the
76+
server only unlinks the socket if its on-disk inode still matches the one it
77+
created.
78+
- **Ghostty for scrollback.** Every byte of program output is fed into an
79+
in-process `ghostty-vt` terminal. On attach the server serializes that
80+
emulator back to VT sequences (optionally including scrollback) so the client
81+
repaints exactly. `ghostty-vt` is imported as a Zig module directly — no
82+
C ABI / FFI. The session retains a fixed 1 MiB scrollback budget.
83+
- **Simple, evolvable framing.** The server/client protocol is a trivial
84+
length-prefixed message format (`[type:u8][len:u32-le][payload]`), chosen to
85+
be easy to read, test and extend. There is intentionally no wire-protocol
86+
compatibility with dtach; unknown message types are ignored, so the protocol
87+
can grow.
88+
89+
## Building
90+
91+
exe-scroll imports Ghostty's `ghostty-vt` Zig module, so it builds against a
92+
[Ghostty](https://github.com/ghostty-org/ghostty) checkout. You need Zig 0.15.2
93+
(macOS also needs the Xcode command-line tools for Ghostty's bundled C++).
94+
95+
```sh
96+
# Just build. Fetches Ghostty at the pinned revision into ./.ghostty for you:
97+
make # -> zig-out/bin/exe-scroll
98+
make test # build + run the test suite
99+
100+
# Already have a Ghostty checkout? Point at it to skip the fetch:
101+
make GHOSTTY_SRC=/path/to/ghostty
102+
103+
# Reproducible static musl build for Linux (fetches the pinned toolchain via
104+
# mise, and a pinned ghostty revision):
105+
./build-static.sh amd64 # or arm64
106+
```
107+
108+
The Zig toolchain version is pinned in `mise.toml` (with per-platform checksums
109+
in `mise.lock`); `build-static.sh` uses [mise](https://mise.jdx.dev) to fetch it
110+
consistently.
111+
112+
## Platforms
113+
114+
The source is POSIX and uses Zig's standard library for all the platform ABI
115+
(termios, signals, ioctls, errno, ...), so it builds and runs natively on
116+
Linux, macOS, and the BSDs. `build-static.sh` is Linux-specific: it produces a
117+
fully static musl binary. (Cross-compiling the macOS binary *from* Linux is not
118+
set up here because Ghostty's bundled C++ SIMD sources need the macOS SDK; build
119+
on the target OS, or wire up an SDK, for a native macOS binary.)
120+
121+
## Testing
122+
123+
`test_e2e.zig` drives the real binary over PTYs — attach-or-create, secure
124+
directory creation, detach (SIGUSR2) / reattach survival, replay modes, color
125+
preservation, and the abduco-style socket recreation. Run it with Zig's test
126+
harness:
127+
128+
```sh
129+
zig build test # builds the binary and runs the e2e tests against it
130+
```
131+
132+
## License
133+
134+
MIT (see [LICENSE](LICENSE)). This is an independent implementation; it does not
135+
reuse dtach's (GPL) source.

exe-scroll/build-static.sh

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
#!/bin/sh
2+
# Reproducible static build of exe-scroll: uses mise to fetch the pinned Zig
3+
# toolchain (versioned + checksummed in mise.toml / mise.lock; the tarball is
4+
# verified against the Zig Software Foundation's signing key), clones Ghostty at
5+
# a pinned revision, and `zig build`s exe-scroll.zig + ghostty-vt into one fully
6+
# static musl binary (no dynamic interpreter at all).
7+
#
8+
# Usage: build-static.sh <target_arch> (arm64 or amd64)
9+
set -e
10+
11+
ARCH="$1"
12+
if [ -z "$ARCH" ]; then
13+
echo "Usage: $0 <target_arch> (arm64 or amd64)" >&2
14+
exit 1
15+
fi
16+
17+
GHOSTTY_REV="${GHOSTTY_REV:-48d3e972d839999745368b156df396d9512fd17b}"
18+
GHOSTTY_REPO_URL="${GHOSTTY_REPO_URL:-https://github.com/ghostty-org/ghostty.git}"
19+
20+
# Directory containing build.zig (this script's directory by default).
21+
SRC_DIR="${SRC_DIR:-$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)}"
22+
23+
case "$ARCH" in
24+
amd64 | x86_64)
25+
ZIG_TARGET="x86_64-linux-musl"
26+
EXPECT_UNAME="x86_64"
27+
;;
28+
arm64 | aarch64)
29+
ZIG_TARGET="aarch64-linux-musl"
30+
EXPECT_UNAME="aarch64"
31+
;;
32+
*)
33+
echo "build-static.sh: unsupported target arch '$ARCH'" >&2
34+
exit 1
35+
;;
36+
esac
37+
38+
if [ "$(uname -m)" != "$EXPECT_UNAME" ]; then
39+
echo "build-static.sh: target $ARCH expects uname -m=$EXPECT_UNAME but got" \
40+
"$(uname -m); run under the target platform (buildx/qemu)." >&2
41+
exit 1
42+
fi
43+
44+
echo "Building exe-scroll for $ARCH (zig target $ZIG_TARGET)..."
45+
46+
# --- Toolchain (zig) via mise -------------------------------------------
47+
# mise.toml pins the Zig version; mise.lock pins per-platform checksums, so the
48+
# toolchain itself is reproducible. We install mise on demand if it isn't
49+
# already present, then let it fetch the exact toolchain. `mise exec` runs the
50+
# build with that zig on PATH.
51+
#
52+
# Note: the on-demand bootstrap below pulls the latest mise (not version-pinned).
53+
# For a fully pinned chain, preinstall a known mise version yourself.
54+
if ! command -v mise >/dev/null 2>&1; then
55+
echo "Installing mise..."
56+
curl -fsSL https://mise.run | sh
57+
export PATH="$HOME/.local/bin:$PATH"
58+
fi
59+
mise trust "$SRC_DIR/mise.toml"
60+
mise install --cd "$SRC_DIR"
61+
62+
# --- Fetch ghostty at the pinned commit ---------------------------------
63+
GHOSTTY_SRC="${GHOSTTY_SRC:-/src/ghostty}"
64+
if [ ! -d "$GHOSTTY_SRC/.git" ]; then
65+
echo "Cloning ghostty..."
66+
git clone --filter=tree:0 "$GHOSTTY_REPO_URL" "$GHOSTTY_SRC"
67+
fi
68+
if ! git -C "$GHOSTTY_SRC" cat-file -e "${GHOSTTY_REV}^{commit}" 2>/dev/null; then
69+
git -C "$GHOSTTY_SRC" fetch --filter=tree:0 origin "$GHOSTTY_REV"
70+
fi
71+
git -C "$GHOSTTY_SRC" checkout --detach "$GHOSTTY_REV"
72+
73+
ln -sfn "$GHOSTTY_SRC" "$SRC_DIR/ghostty-src"
74+
75+
# --- Build --------------------------------------------------------------
76+
(
77+
cd "$SRC_DIR"
78+
rm -rf .zig-cache zig-out
79+
mise exec -- zig build -Dtarget="$ZIG_TARGET" -Doptimize=ReleaseFast
80+
)
81+
82+
OUT="$SRC_DIR/zig-out/bin/exe-scroll"
83+
if [ ! -f "$OUT" ]; then
84+
echo "build-static.sh: exe-scroll binary not produced" >&2
85+
exit 1
86+
fi
87+
strip --strip-all --remove-section=.comment --remove-section=.note "$OUT" 2>/dev/null || true
88+
89+
echo "exe-scroll (static musl) built successfully for $ARCH: $OUT"

exe-scroll/build.zig

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
const std = @import("std");
2+
3+
pub fn build(b: *std.Build) void {
4+
const target = b.standardTargetOptions(.{});
5+
const optimize = b.standardOptimizeOption(.{});
6+
7+
const ghostty = b.dependency("ghostty", .{
8+
.target = target,
9+
.optimize = optimize,
10+
});
11+
12+
const exe = b.addExecutable(.{
13+
.name = "exe-scroll",
14+
.root_module = b.createModule(.{
15+
.root_source_file = b.path("exe-scroll.zig"),
16+
.target = target,
17+
.optimize = optimize,
18+
.link_libc = true,
19+
}),
20+
});
21+
exe.root_module.addImport("ghostty-vt", ghostty.module("ghostty-vt"));
22+
b.installArtifact(exe);
23+
24+
// End-to-end tests (`zig build test`). They drive the real binary over a
25+
// pty, so we hand them its path via build options. The test step depends
26+
// on the executable, so `zig build test` (re)builds it first.
27+
const exe_opts = b.addOptions();
28+
exe_opts.addOptionPath("exe_path", exe.getEmittedBin());
29+
30+
const tests = b.addTest(.{
31+
.root_module = b.createModule(.{
32+
.root_source_file = b.path("test_e2e.zig"),
33+
.target = target,
34+
.optimize = optimize,
35+
.link_libc = true,
36+
}),
37+
});
38+
tests.root_module.addImport("build_options", exe_opts.createModule());
39+
40+
const run_tests = b.addRunArtifact(tests);
41+
run_tests.has_side_effects = true; // always re-run, even if inputs unchanged
42+
const test_step = b.step("test", "Run end-to-end tests against the built binary");
43+
test_step.dependOn(&run_tests.step);
44+
}

exe-scroll/build.zig.zon

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
.{
2+
.name = .exe_scroll,
3+
.version = "0.1.0",
4+
.fingerprint = 0x8e7c6e2e100742c9,
5+
.minimum_zig_version = "0.15.2",
6+
.dependencies = .{
7+
.ghostty = .{ .path = "ghostty-src" },
8+
},
9+
.paths = .{
10+
"build.zig",
11+
"build.zig.zon",
12+
"exe-scroll.zig",
13+
"test_e2e.zig",
14+
},
15+
}

0 commit comments

Comments
 (0)