feat(v0.5.3): centralized tmux primitives (LookupTmux, HasSession, NewSession, AttachSession, PollSessionEnd, ExecTmux*)
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
package shell
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"os/exec"
|
||||
"reflect"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestParseTmuxVersionMajor(t *testing.T) {
|
||||
cases := []struct {
|
||||
raw string
|
||||
wantMajor int
|
||||
}{
|
||||
{"tmux 3.4\n", 3},
|
||||
{"tmux 3.4-rc\n", 3},
|
||||
{"tmux 3.0\n", 3},
|
||||
{"tmux 2.8\n", 2},
|
||||
{"tmux 3\n", 3},
|
||||
{"tmux next-3.5\n", 0}, // unparseable -> Major == 0 (caller proceeds with warning)
|
||||
{"\n", 0},
|
||||
{"random gibberish", 0},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := parseTmuxVersion(c.raw)
|
||||
if got.Major != c.wantMajor {
|
||||
t.Errorf("parseTmuxVersion(%q).Major = %d, want %d", c.raw, got.Major, c.wantMajor)
|
||||
}
|
||||
if got.Raw == "" && c.raw != "\n" && strings.TrimSpace(c.raw) != "" {
|
||||
t.Errorf("parseTmuxVersion(%q).Raw should preserve trimmed input", c.raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTmuxArgsConstruction(t *testing.T) {
|
||||
env := map[string]string{
|
||||
"CTASK_TASK": "demo",
|
||||
"CTASK_MODE": "local",
|
||||
"CTASK_ROOT": "/tmp/root",
|
||||
"CTASK_WORKSPACE": "/tmp/root/projects/demo",
|
||||
"CTASK_CATEGORY": "projects",
|
||||
"CTASK_TYPE": "project",
|
||||
"CTASK_LAUNCH_DIR": "demo",
|
||||
}
|
||||
got := tmuxArgs("ctask-demo-abcdef", "/tmp/root/projects/demo/demo", env, "claude")
|
||||
want := []string{
|
||||
"new-session", "-d", "-s", "ctask-demo-abcdef",
|
||||
"-c", "/tmp/root/projects/demo/demo",
|
||||
"-e", "CTASK_CATEGORY=projects",
|
||||
"-e", "CTASK_LAUNCH_DIR=demo",
|
||||
"-e", "CTASK_MODE=local",
|
||||
"-e", "CTASK_ROOT=/tmp/root",
|
||||
"-e", "CTASK_TASK=demo",
|
||||
"-e", "CTASK_TYPE=project",
|
||||
"-e", "CTASK_WORKSPACE=/tmp/root/projects/demo",
|
||||
"--", "claude",
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("tmuxArgs:\n got: %v\nwant: %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTmuxArgsOmitsEmptyEnvValues(t *testing.T) {
|
||||
env := map[string]string{
|
||||
"CTASK_TASK": "demo",
|
||||
"CTASK_LAUNCH_DIR": "", // empty value
|
||||
}
|
||||
got := tmuxArgs("name", "/dir", env, "shell")
|
||||
for i, a := range got {
|
||||
if a == "-e" && i+1 < len(got) {
|
||||
if strings.HasSuffix(got[i+1], "=") {
|
||||
t.Errorf("empty env value at index %d: %q", i, got[i+1])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookupTmuxNotFound(t *testing.T) {
|
||||
orig := os.Getenv("PATH")
|
||||
t.Cleanup(func() { os.Setenv("PATH", orig) })
|
||||
os.Setenv("PATH", "")
|
||||
|
||||
_, _, err := LookupTmux()
|
||||
if err == nil {
|
||||
t.Fatal("expected error when tmux not on PATH")
|
||||
}
|
||||
if !errors.Is(err, ErrTmuxNotFound) {
|
||||
t.Errorf("expected ErrTmuxNotFound, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookupTmuxRunsVersionParser(t *testing.T) {
|
||||
if _, err := exec.LookPath("tmux"); err != nil {
|
||||
t.Skip("tmux not on PATH")
|
||||
}
|
||||
path, ver, err := LookupTmux()
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrTmuxNotFound) {
|
||||
t.Fatalf("LookupTmux returned ErrTmuxNotFound but tmux is on PATH: %v", err)
|
||||
}
|
||||
if errors.Is(err, ErrTmuxTooOld) {
|
||||
if path == "" {
|
||||
t.Error("ErrTmuxTooOld should still return discovered path")
|
||||
}
|
||||
return
|
||||
}
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if path == "" {
|
||||
t.Error("expected non-empty path")
|
||||
}
|
||||
if ver.Major <= 0 {
|
||||
t.Errorf("expected positive major version, got %d", ver.Major)
|
||||
}
|
||||
if ver.Raw == "" {
|
||||
t.Error("expected non-empty Raw")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPollSessionEndExitsAfterFalse(t *testing.T) {
|
||||
// Sequence: true, true, false -> loop must exit on the third call.
|
||||
calls := 0
|
||||
hs := func(_, _ string) bool {
|
||||
calls++
|
||||
return calls < 3
|
||||
}
|
||||
pollSessionEndWith("/usr/bin/tmux", "ctask-x", 1*time.Millisecond, hs)
|
||||
if calls < 3 {
|
||||
t.Errorf("expected at least 3 calls before exit, got %d", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifyAttachErrorNilOnNil(t *testing.T) {
|
||||
if classifyAttachError(nil) != nil {
|
||||
t.Error("nil input should produce nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifyAttachErrorWrapsExitError(t *testing.T) {
|
||||
var cmd *exec.Cmd
|
||||
if runtime.GOOS == "windows" {
|
||||
cmd = exec.Command("cmd.exe", "/c", "exit 5")
|
||||
} else {
|
||||
cmd = exec.Command("sh", "-c", "exit 5")
|
||||
}
|
||||
err := cmd.Run()
|
||||
if err == nil {
|
||||
t.Fatal("setup: expected sh/cmd to exit non-zero")
|
||||
}
|
||||
got := classifyAttachError(err)
|
||||
if got == nil {
|
||||
t.Fatal("expected wrapped error for non-zero exit")
|
||||
}
|
||||
if !strings.Contains(got.Error(), "exited 5") {
|
||||
t.Errorf("expected 'exited 5' in message: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifyAttachErrorWrapsNonExitError(t *testing.T) {
|
||||
got := classifyAttachError(errors.New("startup failure"))
|
||||
if got == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if !strings.Contains(got.Error(), "startup failure") {
|
||||
t.Errorf("expected wrapped underlying message: %v", got)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user