gtach

package module
v0.0.2 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 5, 2026 License: MIT Imports: 15 Imported by: 0

README

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.

Documentation

Overview

Package gtach runs commands in persistent PTY sessions accessed over Unix sockets. It is a pure-Go implementation of dtach's core idea, not its protocol.

Index

Constants

View Source
const ReplayLimit = 1024 * 1024

ReplayLimit is the maximum number of recent PTY bytes retained when Replay is enabled. Replay is raw output, not a terminal screen snapshot.

Variables

This section is empty.

Functions

This section is empty.

Types

type Client

type Client struct {
	// contains filtered or unexported fields
}

Client is a connection to a session. Read returns raw PTY output. Write sends input without interpreting control characters. One reader and multiple writers may operate concurrently. Close detaches without stopping the command.

func Dial

func Dial(ctx context.Context, socket string) (*Client, error)

Dial connects without subscribing to output, suitable for pushing input. Call Attach to subscribe to output and release a WaitForClient session.

func (*Client) Attach

func (c *Client) Attach() error

Attach subscribes to output, preceded by recent output if the session has Replay enabled. Repeating Attach on the same connection does not replay again.

func (*Client) Close

func (c *Client) Close() error

func (*Client) Read

func (c *Client) Read(b []byte) (int, error)

func (*Client) Redraw

func (c *Client) Redraw(method Redraw) error

func (*Client) Resize

func (c *Client) Resize(rows, cols uint16) error

Resize updates the shared PTY. The most recent client's size wins.

func (*Client) Write

func (c *Client) Write(b []byte) (int, error)

type Options

type Options struct {
	Socket  string
	Command []string
	// Env nil inherits the current environment; Dir empty uses the current directory.
	Env        []string
	Dir        string
	Rows, Cols uint16
	// WaitForClient preserves initial output until the first Attach. Cancel the
	// context or call Close if no client will attach.
	WaitForClient bool
	// Replay retains the last ReplayLimit bytes of output, including output while
	// detached, and sends them once on each connection's first Attach. Off by default.
	Replay bool
}

Options configures a session. Socket must be in an existing, user-owned directory with mode 0700. Existing paths are never removed or overwritten.

type Redraw

type Redraw byte

Redraw selects how an application is asked to refresh when a client attaches.

const (
	RedrawNone Redraw = iota
	RedrawCtrlL
	RedrawWinch
)

type Session

type Session struct {
	// contains filtered or unexported fields
}

Session owns a command, its PTY, and its socket. It remains alive when clients disconnect. The hosting process must remain alive too.

func Start

func Start(ctx context.Context, opts Options) (*Session, error)

Start binds the socket and starts the command before returning. Cancellation terminates the command's process group and closes all client connections.

func (*Session) Close

func (s *Session) Close() error

Close terminates the session and waits for cleanup. It is safe to call twice.

func (*Session) Wait

func (s *Session) Wait() error

Wait waits for cleanup and returns the command's exit error. It may be called repeatedly or concurrently. Output is drained for up to one second after exit.

Directories

Path Synopsis
cmd
gtach command

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL