feat(v0.5.3): centralized tmux primitives (LookupTmux, HasSession, NewSession, AttachSession, PollSessionEnd, ExecTmux*)

This commit is contained in:
2026-05-08 13:50:17 -04:00
parent 1ab1cda111
commit 53adba638e
2 changed files with 401 additions and 0 deletions
+230
View File
@@ -0,0 +1,230 @@
package shell
import (
"errors"
"fmt"
"os"
"os/exec"
"sort"
"strconv"
"strings"
"time"
)
// Version describes a parsed tmux version.
type Version struct {
Major int // 0 if unparseable
Raw string // trimmed output of `tmux -V`, e.g. "tmux 3.4-rc"
}
// parseTmuxVersion extracts the major version from `tmux -V` output.
// Major == 0 signals unparseable input (caller proceeds with a warning
// rather than blocking).
func parseTmuxVersion(raw string) Version {
line := strings.TrimSpace(raw)
parts := strings.Fields(line)
if len(parts) < 2 || parts[0] != "tmux" {
return Version{Raw: line}
}
tok := parts[1]
if idx := strings.IndexByte(tok, '-'); idx >= 0 {
tok = tok[:idx]
}
majorStr := tok
if idx := strings.IndexByte(tok, '.'); idx >= 0 {
majorStr = tok[:idx]
}
n, err := strconv.Atoi(majorStr)
if err != nil || n <= 0 {
return Version{Raw: line}
}
return Version{Major: n, Raw: line}
}
// tmuxArgs builds the argv passed to `tmux` for new-session creation.
// Keys in env are emitted in sorted order so output is deterministic for
// testing. Empty values are skipped: tmux 3.0+ accepts `-e VAR=` to set an
// empty value, but we instead omit the variable so it inherits from the
// child (matching the v0.5 contract that empty CTASK_LAUNCH_DIR means "no
// project subdir"). The trailing `--` ensures the agent's own flags do not
// confuse tmux's argument parser.
func tmuxArgs(sessionName, launchAbs string, env map[string]string, command string, commandArgs ...string) []string {
args := []string{"new-session", "-d", "-s", sessionName, "-c", launchAbs}
keys := make([]string, 0, len(env))
for k, v := range env {
if v == "" {
continue
}
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
args = append(args, "-e", k+"="+env[k])
}
args = append(args, "--", command)
args = append(args, commandArgs...)
return args
}
// ErrTmuxNotFound is returned by LookupTmux when tmux is not on PATH.
var ErrTmuxNotFound = errors.New("tmux not found on PATH")
// ErrTmuxTooOld is returned by LookupTmux when the installed tmux is older
// than MinTmuxMajor.
var ErrTmuxTooOld = errors.New("tmux version too old (requires 3.0+)")
// MinTmuxMajor is the minimum tmux major version supported by ctask
// persistent mode. tmux 3.0 introduced `new-session -e VAR=VAL`, which is
// the only safe way to pass per-session env vars to non-POSIX shells.
const MinTmuxMajor = 3
// LookupTmux locates tmux on PATH and validates its version.
// - ErrTmuxNotFound — PATH lookup failed; returned path is "".
// - ErrTmuxTooOld — version < MinTmuxMajor; path and Version still populated
// so callers can render the discovered version in errors.
// - other error — `tmux -V` failed to execute (returned path populated).
// - nil error with Version{Major:0} — version unparseable (custom builds,
// snapshots); caller may proceed with a warning.
func LookupTmux() (string, Version, error) {
path, err := exec.LookPath("tmux")
if err != nil {
return "", Version{}, ErrTmuxNotFound
}
out, err := exec.Command(path, "-V").CombinedOutput()
if err != nil {
return path, Version{Raw: strings.TrimSpace(string(out))}, fmt.Errorf("running tmux -V: %w", err)
}
v := parseTmuxVersion(string(out))
if v.Major == 0 {
return path, v, nil
}
if v.Major < MinTmuxMajor {
return path, v, ErrTmuxTooOld
}
return path, v, nil
}
// HasSession reports whether tmux currently has a session named `name`.
// Discards stdout/stderr (stderr is chatty on miss with "no server running",
// which is a normal expected outcome).
func HasSession(tmuxPath, name string) bool {
cmd := exec.Command(tmuxPath, "has-session", "-t", name)
cmd.Stdout = nil
cmd.Stderr = nil
return cmd.Run() == nil
}
// PollInterval is the production cadence for PollSessionEnd. Below the
// 30-second heartbeat interval so finalize lag after session end is
// bounded; above 1s to keep CPU and exec overhead negligible.
const PollInterval = 3 * time.Second
// NewSession runs `tmux new-session -d -s name -c launchAbs -e VAR=VAL ...
// -- command [args...]`. Stdout discarded (empty in -d mode); stderr is
// surfaced so config errors / server start failures are visible.
func NewSession(tmuxPath, name, launchAbs string, env map[string]string, command string, args ...string) error {
argv := tmuxArgs(name, launchAbs, env, command, args...)
cmd := exec.Command(tmuxPath, argv...)
cmd.Stdout = nil
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("tmux new-session: %w", err)
}
return nil
}
// AttachSession runs `tmux attach-session -t name` with the user's terminal
// wired through. Returns:
// - nil on clean exit. tmux exits 0 for *both* normal user detach
// (Ctrl-B d) and clean session end while attached — they are
// indistinguishable to the foreground process. The polling loop in the
// caller is what subsequently detects session-end vs detach.
// - an error wrapping the exit code on non-zero exit. Likely causes:
// missing TTY, nested tmux, session disappeared between has-session
// and attach-session, ~/.tmux.conf parse error.
// - an error wrapping any non-ExitError failure (process couldn't start
// at all).
func AttachSession(tmuxPath, name string) error {
cmd := exec.Command(tmuxPath, "attach-session", "-t", name)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return classifyAttachError(cmd.Run())
}
// classifyAttachError converts a `tmux attach-session` cmd.Run() result
// into an error following the AttachSession contract. Exposed for testing
// without invoking tmux.
func classifyAttachError(err error) error {
if err == nil {
return nil
}
if exitErr, ok := err.(*exec.ExitError); ok {
return fmt.Errorf(
"tmux attach-session exited %d (likely cause: missing TTY, nested tmux, session disappeared, or ~/.tmux.conf error): %w",
exitErr.ExitCode(), err)
}
return fmt.Errorf("tmux attach-session: %w", err)
}
// PollSessionEnd blocks until tmux reports the named session is gone.
// Production callers use PollInterval. Internally delegates to
// pollSessionEndWith for testability.
func PollSessionEnd(tmuxPath, name string, interval time.Duration) {
pollSessionEndWith(tmuxPath, name, interval, HasSession)
}
// pollSessionEndWith is the test seam for PollSessionEnd: callers can
// inject a fake HasSession to exercise the loop without invoking tmux.
func pollSessionEndWith(tmuxPath, name string, interval time.Duration, hs func(string, string) bool) {
for hs(tmuxPath, name) {
time.Sleep(interval)
}
}
// ExecTmuxAgent orchestrates the three-call pattern for agent mode:
// NewSession -> AttachSession -> PollSessionEnd.
//
// AttachSession failures abort early — the polling loop is meaningful only
// after a successful attach (otherwise we'd block waiting for a session
// the user never connected to).
func ExecTmuxAgent(tmuxPath, sessionName, launchAbs string, env map[string]string, agent string) error {
if err := NewSession(tmuxPath, sessionName, launchAbs, env, agent); err != nil {
return err
}
if err := AttachSession(tmuxPath, sessionName); err != nil {
return err
}
PollSessionEnd(tmuxPath, sessionName, PollInterval)
return nil
}
// ExecTmuxShell is the shell-mode counterpart to ExecTmuxAgent. The shell
// command comes from DefaultShell(); no PROMPT/PS1 override is wired
// through tmux — tmux handles its own status line and the user's tmux
// config governs prompt customization.
func ExecTmuxShell(tmuxPath, sessionName, launchAbs string, env map[string]string) error {
shellCmd := DefaultShell()
if err := NewSession(tmuxPath, sessionName, launchAbs, env, shellCmd); err != nil {
return err
}
if err := AttachSession(tmuxPath, sessionName); err != nil {
return err
}
PollSessionEnd(tmuxPath, sessionName, PollInterval)
return nil
}
// IsTTY reports whether the given file (typically os.Stdin or os.Stdout)
// is a terminal. tmux attach-session requires both stdin and stdout to be
// TTYs. On Windows this is best-effort via os.Stat + ModeCharDevice.
func IsTTY(f *os.File) bool {
if f == nil {
return false
}
info, err := f.Stat()
if err != nil {
return false
}
return (info.Mode() & os.ModeCharDevice) != 0
}