gtach
Run a command in a persistent PTY, detach, and attach again without stopping it. Pure Go, with a CLI and an importable library. Linux and macOS; no C compiler, cgo, external dtach binary, or terminal emulator.
Inspired by dtach by Ned T. Crigler. This is an independent implementation, not a source translation or a wire-compatible replacement. gtach clients connect only to gtach sessions.
Disclaimer
This project was developed heavily with AI assistance. Treat the code as untrusted until you have reviewed and tested it for your use case. It has not had an independent security audit. No warranty is provided.
Install
Install with Go 1.25 or newer:
go install github.com/pkar/gtach/cmd/gtach@latest
Download a binary from releases, or inspect and run the installer:
curl -fsSLO https://raw.githubusercontent.com/pkar/gtach/main/install.sh
sh install.sh
The installer verifies SHA-256 checksums for Linux amd64/arm64 and macOS arm64 binaries. On other Linux/macOS targets it builds the same release tag with Go. Set GTACH_INSTALL_DIR to choose the install directory. Checksums detect corruption; they are not independent signatures.
Build locally with make, or install with make install PREFIX="$HOME/.local".
CLI
Run gtach with no arguments in any directory. It detects the invoking shell and starts that shell interactively, so its normal startup files configure the prompt. If the parent is not a recognized shell or cannot be inspected, it uses exported $SHELL, then /bin/sh as a last resort. This also handles Bash with an unexported SHELL or a SHELL value that names a different login shell. Press Ctrl-\ to detach, then run bare gtach from the same directory to resume that shell with its state intact. Use exit or Ctrl-D to end the shell; the next invocation starts a new session. Use gtach --help for usage.
New automatic Bash, Zsh, Fish, and POSIX-style shell sessions prefix the prompt with [g] while retaining the user's normal prompt configuration. Bash and Zsh prompt hooks run first, so themes that update the prompt each command keep working. CLI commands also receive GTACH=1 for custom prompt themes and other session-aware tools. Explicitly launched commands and other shells receive the environment marker but are not wrapped. Startup scripts are kept in the private socket directory, not in your dotfiles, and contain no copied environment values. Existing sessions keep their original prompt until restarted.
On attach, CLI sessions replay their last 1 MiB of output so an idle prompt and recent command output are visible without typing. No extra redraw is sent by default: sending Ctrl-L after replay would clear the shell's restored output. Use -r ctrl_l or -r winch explicitly when an application needs a redraw. Output produced while detached is included. This is raw output replay, not a reconstructed screen: the retained tail can start mid-line or mid-escape sequence, and full-screen programs may still need a redraw. Replayed terminal controls are interpreted by your terminal just like live output.
New sessions inherit the caller's exported environment, including PATH, and start in the caller's current directory. The interactive shell can still change its environment through startup files. Reattaching preserves the running shell's environment rather than replacing it with the attaching terminal's environment. Aliases and unexported shell variables are not inherited. Configure your prompt in the shell's normal startup file (for example .bashrc or .zshrc); ad hoc, unexported prompt changes in the outer shell cannot be copied. Existing /bin/sh sessions stay /bin/sh: exit one normally and run bare gtach again to use the detected shell.
Use gtach list or gtach --list without a TTY to show automatic sessions, their directory labels, socket paths, and status (active, stale, or unreachable). For explicitly named sessions, use gtach list "$HOME/.gtach" to inspect that socket directory. Listing probes connections without attaching or sending input, and never removes stale sockets. An active socket means a connection succeeded, not that a client is attached. Only sockets in the selected directory are listed; gtach does not scan the filesystem for custom sockets.
Each resolved directory path gets one session per user. Symlink aliases share a session; different directories get separate sessions. Changing directories inside the session does not change its identity, and renaming the original directory gives it a new identity. Automatic sockets live under /tmp/gtach-<uid>/, not in the project, in a directory owned by you with mode 0700. Directory paths are hashed to keep socket names short. Private .json sidecars store directory labels only, never environment variables; labels for finished sessions may remain but are not listed without a socket. Older sessions show "-" until bare gtach is run again from their directory; explicit sockets without metadata also show "-". Existing unsafe directories and stale sockets are refused, not replaced.
For explicit session names, create a private socket directory and start a shell:
mkdir -p "$HOME/.gtach"
chmod 700 "$HOME/.gtach"
gtach -A "$HOME/.gtach/shell" "$SHELL"
Press Ctrl-\ to detach. Reattach with gtach -a "$HOME/.gtach/shell". Multiple clients can attach and type into the same session. The last resize wins.
| Mode |
Behavior |
| no arguments |
Start or resume the current directory's shell |
-a SOCKET |
Attach to an existing session |
-A SOCKET [COMMAND ...] |
Attach, or create and attach if absent |
-c SOCKET COMMAND ... |
Create and attach; fail if the path exists |
-n SOCKET COMMAND ... |
Create detached and return after startup |
-N SOCKET COMMAND ... |
Create detached with the server in the foreground |
-p SOCKET |
Copy stdin verbatim to the command |
For explicit modes, place options after the socket and before the command. Use -- to end options. Arguments after the command are passed through unchanged; no shell is inserted.
| Option |
Behavior |
-e '^A' |
Change the detach key; default is '^\' |
-E |
Disable detach-key processing |
-z |
Send Ctrl-Z to the command instead of suspending the client |
-r none |
Do not request a redraw; default |
-r ctrl_l |
Send Ctrl-L only in noncanonical, no-echo mode |
-r winch |
Signal the PTY foreground process group with SIGWINCH |
--version |
Print the binary version; use without a mode |
--help, -h |
Print usage without starting a session |
--list, list |
List sessions; optionally pass a socket directory |
By default Ctrl-Z suspends the client and restores the local terminal. Resume it with your shell's fg. Ctrl-C is forwarded to the PTY. Closing the client or losing its terminal leaves the command running. Exit the command normally to end the session; send SIGTERM to a foreground -N server to terminate it.
gtach -n "$HOME/.gtach/job" sh -c 'exec ./long-running-job'
printf 'echo hello\n' | gtach -p "$HOME/.gtach/shell"
gtach -a "$HOME/.gtach/shell" -e '^A' -r winch
Interactive attach requires a TTY on stdin. -n, -N, and -p do not. -N returns the command's success/failure; an attaching client returns success when the connection ends, not the remote command's exit status.
Library
package main
import (
"context"
"io"
"log"
"os"
"path/filepath"
"github.com/pkar/gtach"
)
func main() {
dir, err := os.MkdirTemp("", "gtach-") // mode 0700
if err != nil { log.Fatal(err) }
defer os.RemoveAll(dir)
socket := filepath.Join(dir, "session")
session, err := gtach.Start(context.Background(), gtach.Options{
Socket: socket,
Command: []string{"sh", "-c", "printf 'hello\\n'"},
WaitForClient: true,
})
if err != nil { log.Fatal(err) }
defer session.Close()
client, err := gtach.Dial(context.Background(), socket)
if err != nil { log.Fatal(err) }
defer client.Close()
if err := client.Attach(); err != nil { log.Fatal(err) }
if _, err := io.Copy(os.Stdout, client); err != nil { log.Fatal(err) }
if err := session.Wait(); err != nil { log.Fatal(err) }
}
Start runs in the hosting process, not a daemon. Keep that process alive to preserve the session. Use the context or Session.Close to terminate it, and Session.Wait to get the command's exit error. Close is idempotent and returns the same exit error as Wait, including an error when it kills the command.
Set Options.Replay to retain and replay up to gtach.ReplayLimit (1 MiB) of recent output on each connection's first Attach. The library default is off, preserving live-only stream behavior. Replay and live output are ordered atomically; repeating Attach on one connection does not replay again.
Client implements io.ReadWriteCloser. Dial alone does not subscribe to output; call Attach before reading, or just Write and Close to push input. Resize(rows, cols) updates the shared PTY and Redraw requests a refresh. Closing a client does not close the session. The Dial context controls connection establishment only; close the client to interrupt later I/O.
Boundaries
The socket's immediate parent must exist, belong to the current uid, and have mode 0700. Keep all ancestor directories trusted too. The socket is mode 0600. Anyone who can connect has full access to the session, including command input; this is not a multi-user security boundary.
Existing paths, including stale sockets, are never removed during startup. Confirm a stale session is dead before removing its socket yourself. Normal shutdown removes the socket. Unix socket path-length limits apply.
CLI sessions retain the last 1 MiB of output in server memory, including output while detached; older bytes are discarded. Library sessions discard detached output unless Options.Replay is enabled. PTY output and environment variables are not logged to disk. Upgrading the client does not upgrade an already-running server: existing sessions keep their original replay limit (or stay live-only if created before replay support) until you finish their commands and start new sessions. -c and newly created -A sessions preserve initial output until the first client attaches. A library session with WaitForClient also waits if the command has already exited; cancel or close it if no client will attach. Slow clients are disconnected when their output queue (up to 256 KiB of live output plus a 1 MiB replay snapshot) fills or a write stalls for one second, rather than blocking the command or other clients. Final output drains for up to one second after command exit and queued socket output for up to one more second.
There is no dtach socket interoperability, socket executable-bit status indicator, automatic stale-socket deletion, long-path workaround, or terminal-state replay. Redraw is selected per client. Terminating the server kills the command's original process group and closes the PTY; commands that deliberately escape into separate sessions are not supervised.
The pure-Go dependencies are creack/pty, x/sys, and x/term. Building with CGO_ENABLED=0 is tested. Windows is not supported.
Verify and release
go test -race ./...
CGO_ENABLED=0 go test ./...
go vet ./...
CI runs on Linux and macOS. The release process follows pkar/schain: push a v* tag, pass tests on both OSes, cross-build stripped CGO-disabled binaries, generate checksums.txt, and publish a GitHub release with generated notes. Library versions use the same tags.
After merging and verifying main, create and push the next semver tag:
git switch main
git pull --ff-only
git tag v0.0.2
git push origin v0.0.2
MIT licensed. dtach remains a separate GPL-licensed project.