gtach

package module
v0.0.1 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, terminal emulator, or scrollback.

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

Once a release is published, 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

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
-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

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
-r ctrl_l Send Ctrl-L only in noncanonical, no-echo mode; default
-r winch Signal the PTY foreground process group with SIGWINCH
--version Print the binary version; use without a mode

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.

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.

Output while detached is discarded. -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 256 KiB output queue 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.1
git push origin v0.0.1

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

This section is empty.

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 future output; there is no scrollback or terminal emulator.

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
}

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