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 }